File: Scripts\PRScriptPowerShellTests.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 Aspire.TestUtilities;
using Aspire.Templates.Tests;
using Xunit;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// Tests for the PowerShell PR script (get-aspire-cli-pr.ps1).
/// These tests validate parameter handling using -WhatIf (not -DryRun).
/// PowerShell scripts support -WhatIf as the dry-run equivalent.
/// The mock gh CLI uses top-level goto dispatch on Windows to avoid
/// CMD issues with exit /b inside nested if () blocks.
/// </summary>
[RequiresTools(["pwsh"])]
public class PRScriptPowerShellTests(ITestOutputHelper testOutput)
{
    private static readonly string s_scriptPath = ScriptPaths.PRPowerShell;
    private readonly ITestOutputHelper _testOutput = testOutput;
 
    private async Task<ScriptToolCommand> CreateCommandWithMockGhAsync(TestEnvironment env)
    {
        var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
        var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        return cmd;
    }
 
    [Fact]
    public async Task GetHelp_WithQuestionMark_ShowsUsage()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var result = await cmd.ExecuteAsync("-?");
 
        result.EnsureSuccessful();
        Assert.True(
            result.Output.Contains("SYNOPSIS", StringComparison.OrdinalIgnoreCase) ||
            result.Output.Contains("DESCRIPTION", StringComparison.OrdinalIgnoreCase) ||
            result.Output.Contains("PARAMETERS", StringComparison.OrdinalIgnoreCase),
            "Output should contain help information");
    }
 
    [Fact]
    public async Task AllMainParameters_ShownInHelp()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var result = await cmd.ExecuteAsync("-?");
 
        result.EnsureSuccessful();
 
        // PowerShell help wraps long lines, which can split parameter names across lines
        // (e.g., "PRNumb\n    er]"). Normalize by removing newlines and continuation whitespace.
        var normalized = System.Text.RegularExpressions.Regex.Replace(result.Output, @"\r?\n\s*", "");
 
        Assert.Contains("PRNumber", normalized);
        Assert.Contains("LocalDir", normalized);
        Assert.Contains("HiveLabel", normalized);
        Assert.Contains("InstallPath", normalized);
        Assert.Contains("OS", normalized);
        Assert.Contains("Architecture", normalized);
        Assert.Contains("HiveOnly", normalized);
        Assert.Contains("SkipExtension", normalized);
        Assert.Contains("UseInsiders", normalized);
        Assert.Contains("SkipPath", normalized);
        Assert.Contains("KeepArchive", normalized);
    }
 
    [Fact]
    public async Task MissingPRNumber_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("PRNumber", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task WhatIfWithPRNumber_ShowsSteps()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("12345", result.Output);
    }
 
    [Fact]
    public async Task MockGhApiWithoutJqFailsLoudly()
    {
        using var env = new TestEnvironment();
        var mockBinPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockGhPath = Path.Combine(mockBinPath, OperatingSystem.IsWindows() ? "gh.cmd" : "gh");
        using var cmd = new ToolCommand(mockGhPath, _testOutput, "mock-gh");
 
        var result = await cmd.ExecuteAsync("api", "repos/microsoft/aspire/pulls/12345");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("_mock_missing_jq", result.Output);
    }
 
    [Fact]
    public async Task MockGhApiGraphQlWithoutJqFailsLoudly()
    {
        using var env = new TestEnvironment();
        var mockBinPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockGhPath = Path.Combine(mockBinPath, OperatingSystem.IsWindows() ? "gh.cmd" : "gh");
        using var cmd = new ToolCommand(mockGhPath, _testOutput, "mock-gh");
 
        var result = await cmd.ExecuteAsync("api", "graphql");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("_mock_missing_jq", result.Output);
    }
 
    [Fact]
    public async Task MockGhApiGraphQlWithJqReturnsHeadSha()
    {
        using var env = new TestEnvironment();
        var mockBinPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockGhPath = Path.Combine(mockBinPath, OperatingSystem.IsWindows() ? "gh.cmd" : "gh");
        using var cmd = new ToolCommand(mockGhPath, _testOutput, "mock-gh");
 
        var result = await cmd.ExecuteAsync("api", "graphql", "--jq", ".data.repository.pullRequest.headRefOid");
 
        result.EnsureSuccessful();
        Assert.Equal("abc123def456789012345678901234567890abcd", result.Output.Trim());
    }
 
    [Fact]
    public async Task CustomInstallPath_IsRecognized()
    {
        using var env = new TestEnvironment();
        var customPath = Path.Combine(env.TempDirectory, "custom");
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-InstallPath", customPath, "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains(customPath, result.Output);
    }
 
    [Fact]
    public async Task RunIdParameter_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-WorkflowRunId", "987654321", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("987654321", result.Output);
    }
 
    [Fact]
    public async Task WorkflowRunIdOnly_NoHiveLabel_WhatIf_Rejected()
    {
        // Regression guard: -WorkflowRunId alone (without -PRNumber / -HiveLabel) must
        // fail with actionable guidance. The installed CLI's channel is baked at build
        // time (pr-<N>/staging/daily/local); 'run-<id>' is never a baked channel, so a
        // hives/run-<id>/packages layout would be unreachable from the CLI.
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-WorkflowRunId", "987654321", "-WhatIf", "-SkipPath", "-Verbose");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Cannot determine hive label from -WorkflowRunId alone", result.Output);
        Assert.Contains("-PRNumber", result.Output);
        Assert.Contains("-HiveLabel", result.Output);
        Assert.DoesNotContain("run-987654321", result.Output);
    }
 
    [Fact]
    public async Task LocalDir_WhatIf_UsesLocalDirectoryWithoutGh()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var localDir = Path.Combine(env.TempDirectory, "local artifacts");
        Directory.CreateDirectory(localDir);
        await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-preview.1.12345.1");
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-HiveLabel", "test-hive",
            "-HiveOnly",
            "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("Installing from local directory", result.Output);
        Assert.Contains(localDir, result.Output);
        Assert.Contains("test-hive", result.Output);
        Assert.DoesNotContain("Downloading CLI from GitHub", result.Output);
    }
 
    [Fact]
    public async Task LocalDir_WhatIf_AutoDetectsRawBuildOutput()
    {
        // When -LocalDir points at a directory that contains only a raw 'aspire' executable
        // (no aspire-cli-*.tar.gz/.zip archive), auto-detect must dispatch to the raw-build flow
        // (Install-AspireCliFromLocalBinary) instead of erroring on a missing archive.
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var localDir = Path.Combine(env.TempDirectory, "raw-build");
        Directory.CreateDirectory(localDir);
        // Drop a fake 'aspire' executable — no archive, no nupkgs.
        await File.WriteAllTextAsync(Path.Combine(localDir, "aspire"), "#!/bin/sh\necho fake\n");
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-HiveLabel", "test-hive",
            "-WhatIf",
            "-SkipPath");
 
        result.EnsureSuccessful();
        Assert.Contains("Installing from local directory", result.Output);
        // WhatIf preview from Install-AspireCliFromLocalBinary's ShouldProcess message
        // (or the script's error if dispatch is wrong) — we expect the raw-binary path.
        Assert.Contains("raw Aspire CLI binary tree", result.Output);
        Assert.DoesNotContain("Downloading CLI from GitHub", result.Output);
    }
 
    [Fact]
    public async Task OSOverride_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-OS", "win", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("win", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task ArchOverride_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-Architecture", "x64", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("x64", result.Output);
    }
 
    [Fact]
    public async Task HiveOnlyFlag_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-HiveOnly", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("HiveOnly", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task SkipExtensionFlag_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-SkipExtension", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("SkipExtension", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task SkipPathFlag_IsRecognized()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("Skipping PATH", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task MultipleFlags_WorkTogether()
    {
        using var env = new TestEnvironment();
        var customPath = Path.Combine(env.TempDirectory, "custom");
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "-PRNumber", "12345",
            "-InstallPath", customPath,
            "-OS", "linux",
            "-Architecture", "arm64",
            "-SkipExtension",
            "-SkipPath",
            "-KeepArchive",
            "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains(customPath, result.Output);
    }
 
    [Fact]
    public async Task InvalidPRNumber_NonNumeric_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "abc", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
    }
 
    [Fact]
    public async Task InvalidPRNumber_Zero_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "0", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
    }
 
    [Fact]
    public async Task InvalidPRNumber_Negative_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "-1", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
    }
 
    [Fact]
    public async Task DryRun_ShowsDefaultInstallPath()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains(".aspire", result.Output);
    }
 
    [Fact]
    public async Task AspireRepoEnvVar_IsUsedInDryRun()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
        cmd.WithEnvironmentVariable("ASPIRE_REPO", "my-org/my-aspire");
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-Verbose", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("my-org/my-aspire", result.Output);
    }
 
    [Fact]
    public async Task DryRun_ShowsArtifactNameWithRid()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "-PRNumber", "12345",
            "-OS", "linux",
            "-Architecture", "x64",
            "-Verbose",
            "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("cli-native", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task DryRun_ShowsNugetHivePath()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-Verbose", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("hive", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task HiveOnly_SkipsCLIDownload()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "12345", "-HiveOnly", "-Verbose", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("Skipping CLI download", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task LocalDir_WhatIf_WithGitHubRunIdEnvSet_UsesLocalHiveLabel()
    {
        // Regression guard: the GITHUB_RUN_ID env var must NOT influence the hive label
        // when -LocalDir is used. Without this test, re-introducing a GITHUB_RUN_ID
        // branch would produce "run-99999" silently instead of the expected "local"
        // hive label.
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var localDir = Path.Combine(env.TempDirectory, "local-artifacts");
        Directory.CreateDirectory(localDir);
        // Non-PR-style version ensures auto-detect falls through to "local" label.
        await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-dev.1");
 
        // Inject GITHUB_RUN_ID only into the launched process — not the test process environment.
        cmd.WithEnvironmentVariable("GITHUB_RUN_ID", "99999");
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-HiveOnly",
            "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("Using hive label: local", result.Output, StringComparison.Ordinal);
        Assert.DoesNotContain("run-99999", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    // PR-route CLI binary lands at <prefix>/dogfood/pr-<N>/bin so PR installs
    // do not collide with the script-route prefix or with other PR installs.
    // Under -WhatIf the script emits the absolute install path via a
    // "What if: Aspire CLI binary would be installed to: <path>" message on stdout
    // (PS-native WhatIf style); this test parses that message to verify the
    // resolved install path.
    [Fact]
    public async Task WhatIf_PRRoute_CliInstallPath_IsUnderDogfoodPrN()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        var binaryName = OperatingSystem.IsWindows() ? "aspire.exe" : "aspire";
        var expectedBinaryPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", binaryName);
        Assert.Contains($"What if: Aspire CLI binary would be installed to: {expectedBinaryPath}", result.Output);
    }
 
    // Under -WhatIf the PR-route script must NOT write the source=pr sidecar
    // at <prefix>/dogfood/pr-<N>/.aspire-install.json. The describe-but-do-not-do
    // contract requires the script to print a "What if:" message naming the path
    // it would write, then return without touching the filesystem. Positive
    // sidecar content (the source field) for the PR route is covered by
    // end-to-end install runs, not at this unit-test layer.
    [Fact]
    public async Task WhatIf_PRRoute_DoesNotWriteSidecar_AndAnnouncesPath()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
 
        var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", ".aspire-install.json");
        Assert.Contains($"What if: Route sidecar would be written to: {sidecarPath}", result.Output);
        Assert.False(
            File.Exists(sidecarPath),
            $"Expected no sidecar to be written under -WhatIf, but found one at {sidecarPath}");
    }
 
    // PR-route install prints the PATH-activation hint via Write-Host. The
    // OS path separator keeps the line valid on both Windows (;) and Unix (:).
    // The new-PATH expression must be double-quoted so PowerShell expands
    // `$env:PATH` when the user pastes the line into their profile — single
    // quotes would assign the literal text and clobber PATH.
    //
    // Separator before `$env:PATH` must be [System.IO.Path]::PathSeparator — a
    // hard-coded ":" or ";" breaks the hint on the opposite platform.
    [Fact]
    public async Task WhatIf_PRRoute_PrintsPathHintToStdout()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        var hintLine = ExtractShellProfileHintLine(result.Output);
        Assert.Contains("$env:PATH", hintLine);
        Assert.Contains(Path.Combine("dogfood", "pr-99999", "bin"), hintLine);
        var binDir = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin");
        // The assignment value must be wrapped in double quotes so that
        // $env:PATH expands at user-paste time. Single quotes would make the
        // user's PATH become the literal string "...bin;$env:PATH".
        Assert.Contains($"\"{binDir}", hintLine);
        Assert.DoesNotContain($"'{binDir}", hintLine);
        // Separator joining the new bin dir to `$env:PATH` must come from
        // [System.IO.Path]::PathSeparator, not a hard-coded ":" / ";".
        Assert.Contains($"{binDir}{Path.PathSeparator}$env:PATH", hintLine);
    }
 
    [Fact]
    public async Task WhatIf_PRRoute_PrintsPathHintWithAbsoluteInstallPath()
    {
        using var env = new TestEnvironment();
        var customPath = Path.Combine(env.TempDirectory, "custom-prefix");
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf", "-InstallPath", customPath);
 
        result.EnsureSuccessful();
        var hintLine = ExtractShellProfileHintLine(result.Output);
        var binDir = Path.Combine(customPath, "dogfood", "pr-99999", "bin");
        Assert.Contains($"\"{binDir}", hintLine);
        Assert.Contains($"{binDir}{Path.PathSeparator}$env:PATH", hintLine);
        Assert.DoesNotContain(Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin"), hintLine);
    }
 
    private static string ExtractShellProfileHintLine(string output)
    {
        foreach (var rawLine in output.Split('\n'))
        {
            var line = rawLine.TrimEnd('\r');
            if (line.Contains("Add to your shell profile:", StringComparison.Ordinal))
            {
                return line;
            }
        }
 
        Assert.Fail($"Expected an 'Add to your shell profile:' line in output, but none was emitted. Output was:\n{output}");
        return string.Empty;
    }
 
    // PR-route hive location is unchanged at <prefix>/hives/pr-<N>/packages.
    [Fact]
    public async Task WhatIf_PRRoute_HiveLocation_IsUnchanged()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-Verbose", "-WhatIf");
 
        result.EnsureSuccessful();
        var expectedHive = Path.Combine("hives", "pr-99999", "packages");
        Assert.Contains(expectedHive, result.Output);
    }
 
    // The PR-route script must not mutate route sidecars under -WhatIf.
    // Companion to WhatIf_PRRoute_DoesNotWriteSidecar_AndAnnouncesPath; kept as a
    // separate test method to keep -WhatIf sidecar-absence coverage visible in
    // test inventories alongside the other -WhatIf guards.
    [Fact]
    public async Task WhatIf_PRRoute_DoesNotWriteSidecar_AnyContentRegression()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", ".aspire-install.json");
        Assert.False(
            File.Exists(sidecarPath),
            $"Expected no sidecar to be written under -WhatIf, but found one at {sidecarPath}");
    }
 
    // Under -WhatIf no global aspire.config.json is created.
    [Fact]
    public async Task WhatIf_PRRoute_DoesNotCreateGlobalAspireConfigJson()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999", "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        var globalConfig = Path.Combine(env.MockHome, ".aspire", "aspire.config.json");
        Assert.False(File.Exists(globalConfig), $"Unexpected global config at {globalConfig}");
    }
 
    // Spec: --local-dir / -LocalDir installs are unmanaged. The CLI artifacts come from
    // a directory the user already has — there is no self-update path. The script MUST
    // therefore NOT write a sidecar for the install: a sidecar would falsely advertise a
    // managed source. With no sidecar, downstream commands (e.g. `aspire update --self`)
    // refuse to assume any update channel.
    [Fact]
    public async Task WhatIf_LocalDir_DoesNotWriteSidecar()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
        var localDir = Path.Combine(env.TempDirectory, "local-artifacts");
        Directory.CreateDirectory(localDir);
        await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-preview.1.12345.1");
        var rid = OperatingSystem.IsMacOS() ? (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "osx-arm64" : "osx-x64")
                : OperatingSystem.IsLinux() ? (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "linux-arm64" : "linux-x64")
                : "win-x64";
        await FakeArchiveHelper.CreateFakeArchiveAsync(localDir, rid);
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-HiveLabel", "test-hive",
            "-SkipPath",
            "-WhatIf");
 
        result.EnsureSuccessful();
 
        // The new canonical sidecar position is <prefix>/bin/.aspire-install.json.
        // -LocalDir without a PR number is an unmanaged install, so the script must
        // not write a sidecar; the resolver should return Unknown for these installs.
        var binSidecar = Path.Combine(env.MockHome, ".aspire", "bin", ".aspire-install.json");
        Assert.False(File.Exists(binSidecar), $"--local-dir install must not write sidecar at {binSidecar} (unmanaged route).");
 
        // Defensive: assert no .aspire-install.json anywhere under the install root.
        var aspireRoot = Path.Combine(env.MockHome, ".aspire");
        if (Directory.Exists(aspireRoot))
        {
            var anySidecar = Directory.GetFiles(aspireRoot, ".aspire-install.json", SearchOption.AllDirectories);
            Assert.Empty(anySidecar);
        }
    }
 
    // PR_NUMBER input validation — empty string. PowerShell parameter binding
    // rejects empty strings for [int] parameters before any path construction occurs.
    [Fact]
    public async Task EmptyPRNumber_ReturnsError_AndCreatesNoFiles()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        AssertNoDogfoodInstall(env.MockHome);
    }
 
    // Very large PR number above [int]::MaxValue (2147483647). PowerShell's [int]
    // parameter binding fails the cast — the script must reject and create no files.
    [Fact]
    public async Task VeryLargePRNumber_AboveIntMax_Rejected_AndCreatesNoFiles()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", "99999999999", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        AssertNoDogfoodInstall(env.MockHome);
    }
 
    // Non-numeric / special-char PR_NUMBER is rejected at parameter
    // binding ([int] cast or ValidateRange). This guards against path injection / command
    // injection routes via the PR_NUMBER value.
    [Theory]
    [InlineData("../etc")]
    [InlineData("..")]
    [InlineData("12345 hello")]
    [InlineData("12345;rm")]
    [InlineData("12345|cat")]
    [InlineData("$(whoami)")]
    public async Task SpecialCharsPRNumber_Rejected_AndCreatesNoFiles(string pr)
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", pr, "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        AssertNoDogfoodInstall(env.MockHome);
    }
 
    // PowerShell binds -PRNumber as [int] with [ValidateRange(1, int.MaxValue)], so
    // zero and negative values fail validation. Leading-zero strings like "00001" are
    // accepted because PowerShell applies the [int] cast before parameter-validation
    // attributes; the bash counterpart enforces the leading-zero contract via regex
    // (see PRScriptShellTests.LeadingZeroOrNegativePRNumber_Rejected_AndCreatesNoFiles).
    [Theory]
    [InlineData("0")]
    [InlineData("-1")]
    public async Task NonPositivePRNumber_Rejected_AndCreatesNoFiles(string pr)
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("-PRNumber", pr, "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        AssertNoDogfoodInstall(env.MockHome);
    }
 
    private static void AssertNoDogfoodInstall(string mockHome)
    {
        var dogfoodRoot = Path.Combine(mockHome, ".aspire", "dogfood");
        if (Directory.Exists(dogfoodRoot))
        {
            var leaks = Directory.GetFileSystemEntries(dogfoodRoot, "*", SearchOption.AllDirectories);
            Assert.True(leaks.Length == 0, $"Unexpected files under {dogfoodRoot}: {string.Join(", ", leaks)}");
        }
    }
}