File: Commands\CompletionScriptTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Diagnostics;
using System.Text;
using Aspire.Cli.Completions;
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class CompletionScriptTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [RequiresTools(["pwsh"])]
    [InlineData("aspire ", "", "aspire ")]
    [InlineData("aspire na", "na", "aspire na")]
    [InlineData("echo x; aspire na", "na", "aspire na")]
    [InlineData("& 'C:\\Program Files\\Aspire\\aspire.exe' na", "na", "aspire na")]
    [InlineData("aspire 'na", "'na", "aspire 'na")]
    public async Task PowerShell_PreservesArgumentsAndQuotesSuggestions(string input, string word, string expectedLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "completion.ps1");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("pwsh"));
        var script = """
            $ErrorActionPreference = 'Stop'
            function Register-ArgumentCompleter {
                param([switch]$Native, $CommandName, $ScriptBlock)
                $script:completer = $ScriptBlock
            }
            function aspire {
                if ($args.Count -ne 2 -or $args[0] -ne '[suggest]') { throw 'Invalid suggestion protocol' }
                $script:line = $args[1]
                'name with space'
                "name'quote"
                'name$(Get-Process)'
                '--help'
            }
            . $env:COMPLETION_SCRIPT
            $ast = [System.Management.Automation.Language.Parser]::ParseInput($env:COMPLETION_INPUT, [ref]$null, [ref]$null)
            $command = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true))[-1]
            $results = @(& $script:completer $env:COMPLETION_WORD $command $env:COMPLETION_INPUT.Length)
            $script:line
            $results | ForEach-Object CompletionText
            """;
        var output = await RunShellAsync("pwsh", ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Convert.ToBase64String(Encoding.Unicode.GetBytes(script))],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["COMPLETION_INPUT"] = input,
                ["COMPLETION_WORD"] = word
            });
 
        string[] expected = word.Length == 0
            ? [expectedLine, "'name with space'", "'name''quote'", "'name$(Get-Process)'", "--help"]
            : [expectedLine, "'name with space'", "'name''quote'", "'name$(Get-Process)'"];
        Assert.Equal(expected, output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PowerShell_CompletesNpmStyleScriptShim()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "completion.ps1");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("pwsh"));
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.ps1"), """
            if ($args.Count -ne 2 -or $args[0] -ne '[suggest]' -or $args[1] -ne 'aspire comp') {
                throw 'Invalid completion request'
            }
            'completions'
            """);
        var script = """
            $ErrorActionPreference = 'Stop'
            . $env:COMPLETION_SCRIPT
            $line = 'aspire comp'
            (TabExpansion2 $line $line.Length).CompletionMatches.CompletionText
            """;
        var output = await RunShellAsync("pwsh", ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Convert.ToBase64String(Encoding.Unicode.GetBytes(script))],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["PATH"] = workspace.WorkspaceRoot.FullName + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.Equal("completions", output.Trim());
    }
 
    [Theory]
    [RequiresTools(["bash"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses a Unix executable shim and permissions.")]
    [InlineData("na")]
    [InlineData("naXYZ")]
    public async Task Bash_UsesCursorAndPreservesSuggestionBoundaries(string word)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "completion.bash");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("bash"));
        var binaryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire");
        await File.WriteAllTextAsync(binaryPath, ("""
            #!/bin/sh
            test "$#" -eq 2 && test "$1" = '[suggest:tokens]' && test "$2" = 'na' || exit 1
            printf '%s\n' 'name with space' "name'quote" 'name$(id)'
            """ + "\n").ReplaceLineEndings("\n"));
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
        var script = """
            source "$COMPLETION_SCRIPT"
            COMP_LINE="aspire $COMPLETION_WORD --help"
            COMP_POINT=9
            COMP_WORDS=(aspire "$COMPLETION_WORD" --help)
            COMP_CWORD=1
            _aspire_complete
            printf '%s\n' "${COMPREPLY[@]}"
            """;
        var output = await RunShellAsync("bash", ["--noprofile", "--norc", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["COMPLETION_WORD"] = word,
                ["PATH"] = workspace.WorkspaceRoot.FullName + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.Equal(["name\\ with\\ space", "name\\'quote", "name\\$\\(id\\)"], output.Split('\n', StringSplitOptions.RemoveEmptyEntries));
    }
 
    [Theory]
    [RequiresTools(["bash"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses a Unix executable shim and permissions.")]
    [InlineData("echo 'a;b'; aspire --log-level \"Deb", new[] { "--log-level", "Deb" })]
    [InlineData("echo x && aspire config set 'a b' \"c d", new[] { "config", "set", "a b", "c d" })]
    [InlineData("echo x | aspire config get a\\:b", new[] { "config", "get", "a:b" })]
    [InlineData("aspire run --apphost='a b' --log-level ", new[] { "run", "--apphost=a b", "--log-level", "" })]
    [InlineData("aspire config get \"a\\qb", new[] { "config", "get", "a\\qb" })]
    [InlineData("aspire config set $'a b' x", new[] { "config", "set", "a b", "x" })]
    [InlineData("aspire config set $'a\\tb\\n' x", new[] { "config", "set", "a\tb\n", "x" })]
    [InlineData("aspire config set $'it\\'s \\\\ literal' x", new[] { "config", "set", "it's \\ literal", "x" })]
    [InlineData("aspire config set $'\\141\\x20\\142' x", new[] { "config", "set", "a b", "x" })]
    [InlineData("aspire config set $'\\0123\\cA' x", new[] { "config", "set", "\n3\u0001", "x" })]
    [InlineData("aspire config set pre$'a\\0discarded'post x", new[] { "config", "set", "preapost", "x" })]
    [InlineData("aspire config set $'\\x\\q\\?' x", new[] { "config", "set", "\\x\\q?", "x" })]
    [InlineData("aspire config get $'a\\tb", new[] { "config", "get", "a\tb" })]
    [InlineData("aspire config get $'a\\\\", new[] { "config", "get", "a\\" })]
    [InlineData("aspire config set $'$(touch completion-executed);`touch completion-executed`' x", new[] { "config", "set", "$(touch completion-executed);`touch completion-executed`", "x" })]
    public async Task Bash_QueriesOnlyDecodedCurrentCommandArguments(string line, string[] expectedArguments)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "completion.bash");
        var capturedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "arguments.bin");
        var binaryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("bash"));
        await File.WriteAllTextAsync(binaryPath, "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$CAPTURED_ARGS\"\n");
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
        var script = """
            source "$COMPLETION_SCRIPT"
            COMP_LINE="$COMPLETION_LINE"
            COMP_POINT=${#COMP_LINE}
            _aspire_complete
            """;
        var output = await RunShellAsync("bash", ["--noprofile", "--norc", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["COMPLETION_LINE"] = line,
                ["CAPTURED_ARGS"] = capturedPath,
                ["PATH"] = workspace.WorkspaceRoot.FullName + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.Equal(string.Empty, output);
        Assert.Equal(Encoding.UTF8.GetBytes(string.Join('\0', new[] { "[suggest:tokens]" }.Concat(expectedArguments)) + "\0"),
            await File.ReadAllBytesAsync(capturedPath));
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "completion-executed")));
    }
 
    [Fact]
    [RequiresTools(["bash"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses a Unix executable shim and permissions.")]
    public async Task Bash_AnsiCEscapesUseTheShellLocaleAndVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "completion.bash");
        var capturedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "arguments.bin");
        var binaryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("bash"));
        await File.WriteAllTextAsync(binaryPath, "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$CAPTURED_ARGS\"\n");
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
        var script = """
            source "$COMPLETION_SCRIPT"
            COMP_LINE="aspire config get \$'\u263a\U0001f600\c?'"
            COMP_POINT=${#COMP_LINE}
            _aspire_complete
            printf '%s\0' '[suggest:tokens]' config get $'\u263a\U0001f600\c?'
            """;
        var expected = await RunShellAsync("bash", ["--noprofile", "--norc", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["CAPTURED_ARGS"] = capturedPath,
                ["PATH"] = workspace.WorkspaceRoot.FullName + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        // Older Bash versions retain Unicode escapes literally and decode \c? differently.
        // Compare with the shell's own decoding rather than imposing another version's rules.
        Assert.Equal(Encoding.UTF8.GetBytes(expected), await File.ReadAllBytesAsync(capturedPath));
    }
 
    [Fact]
    [RequiresTools(["fish"])]
    public async Task Fish_RepeatedLoadingPreservesOtherRegistrations()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.fish");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("fish"));
        var script = """
            complete --command aspire --arguments custom
            source "$COMPLETION_SCRIPT"
            source "$COMPLETION_SCRIPT"
            complete --command aspire | count
            complete --erase --command aspire
            complete --command aspire --arguments custom
            source "$COMPLETION_SCRIPT"
            source "$COMPLETION_SCRIPT"
            complete --command aspire | count
            """;
        var output = await RunShellAsync("fish", ["--no-config", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string> { ["COMPLETION_SCRIPT"] = completionPath });
 
        Assert.Equal("2\n2\n", output.ReplaceLineEndings("\n"));
    }
 
    [Fact]
    [RequiresTools(["zsh"])]
    public async Task Zsh_InitializesCompletionForFreshProfile()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.zsh");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("zsh"));
        var script = """
            source "$COMPLETION_SCRIPT"
            [[ "${_comps[aspire]}" == _aspire ]] || exit 1
            print -r -- registered
            """;
        var output = await RunShellAsync("zsh", ["-f", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["HOME"] = workspace.WorkspaceRoot.FullName,
                ["ZDOTDIR"] = workspace.WorkspaceRoot.FullName
            });
 
        Assert.Equal("registered", output.Trim());
    }
 
    [Fact]
    [RequiresTools(["zsh"])]
    [SkipOnPlatform(TestPlatforms.Windows, "Uses a Unix executable shim and permissions.")]
    public async Task Zsh_AutoloadCompletesFirstInvocationWithNativeCommandContext()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var completionPath = Path.Combine(workspace.WorkspaceRoot.FullName, "_aspire");
        var binaryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire");
        var capturedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "arguments.bin");
        await File.WriteAllTextAsync(completionPath, CompletionScripts.Generate("zsh"));
        await File.WriteAllTextAsync(binaryPath, "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$CAPTURED_ARGS\"\nprintf '%s\\n' Debug\n");
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
        var script = """
            fpath=("$HOME" $fpath)
            autoload -Uz compinit
            compinit -D -i
            # Autoload registration must invoke the file body on the first request.
            [[ "${_comps[aspire]}" == _aspire ]] || exit 1
            BUFFER='echo ignored; aspire run --apphost "a b" --log-level "DeTAIL'
            words=(aspire run --apphost '"a b"' --log-level '"DeTAIL')
            CURRENT=6
            PREFIX=De
            compadd() { printf '%s\n' "$@"; }
            _aspire
            """;
        var output = await RunShellAsync("zsh", ["-f", "-c", script.ReplaceLineEndings("\n")],
            new Dictionary<string, string>
            {
                ["COMPLETION_SCRIPT"] = completionPath,
                ["HOME"] = workspace.WorkspaceRoot.FullName,
                ["ZDOTDIR"] = workspace.WorkspaceRoot.FullName,
                ["CAPTURED_ARGS"] = capturedPath,
                ["PATH"] = workspace.WorkspaceRoot.FullName + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.Equal("--\nDebug\n", output.ReplaceLineEndings("\n"));
        Assert.Equal(Encoding.UTF8.GetBytes("[suggest:tokens]\0run\0--apphost\0a b\0--log-level\0De\0"),
            await File.ReadAllBytesAsync(capturedPath));
    }
 
    private static async Task<string> RunShellAsync(string executable, string[] arguments, Dictionary<string, string> environment)
    {
        var startInfo = new ProcessStartInfo(executable)
        {
            UseShellExecute = false,
            WorkingDirectory = Path.GetDirectoryName(environment["COMPLETION_SCRIPT"]),
            RedirectStandardOutput = true,
            RedirectStandardError = true
        };
        foreach (var argument in arguments)
        {
            startInfo.ArgumentList.Add(argument);
        }
        foreach (var (key, value) in environment)
        {
            startInfo.Environment[key] = value;
        }
 
        using var process = Process.Start(startInfo)!;
        var stdout = process.StandardOutput.ReadToEndAsync();
        var stderr = process.StandardError.ReadToEndAsync();
        try
        {
            await process.WaitForExitAsync().DefaultTimeout();
            Assert.True(process.ExitCode == 0, await stderr);
            Assert.Equal(string.Empty, await stderr);
            return await stdout;
        }
        finally
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
                await process.WaitForExitAsync().DefaultTimeout();
            }
        }
    }
}