// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using Aspire.TestUtilities;
using Microsoft.DotNet.XUnitExtensions;
using Xunit;
namespace Aspire.Acquisition.Tests.Scripts;
[RequiresTools(["pwsh"])]
public class CompletionPowerShellTests(ITestOutputHelper testOutput)
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_DirectDirectoryCreationFailureIsNonFatal(bool dogfood)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
var blockedDirectory = Path.GetDirectoryName(CompletionPath(env.MockHome, cli, dogfood))!;
Directory.CreateDirectory(Path.GetDirectoryName(blockedDirectory)!);
File.WriteAllText(blockedDirectory, "# existing file, not a directory");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Contains("CLI installation is unaffected", result.Output);
Assert.Equal("# existing file, not a directory", File.ReadAllText(blockedDirectory));
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletionBoundary_AcceptsVolumeRootWithoutWritingToIt(bool dogfood)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
"""
$root = [IO.Path]::GetPathRoot($HOME)
$destination = Join-Path $root ('aspire-boundary-' + [Guid]::NewGuid().ToString('N') + '/aspire.ps1')
if (-not (Test-CompletionPathWithinRoot -Path $destination -Root $root)) {
throw 'Filesystem root was rejected.'
}
'accepted'
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("accepted", result.Output.Trim());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletionBoundary_SelectedRootAliasIsAnchorButChildRedirectIsRejected(bool dogfood)
{
using var env = new TestEnvironment();
var target = Path.Combine(env.TempDirectory, "actual");
var selected = Path.Combine(env.TempDirectory, "selected");
var outside = Path.Combine(env.TempDirectory, "outside");
Directory.CreateDirectory(target);
Directory.CreateDirectory(outside);
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
$linkType = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }
New-Item -ItemType $linkType -Path '{{Quote(selected)}}' -Target '{{Quote(target)}}' -ErrorAction Stop | Out-Null
$destination = '{{Quote(Path.Combine(selected, "completions", "aspire.ps1"))}}'
if (-not (Test-CompletionPathWithinRoot -Path $destination -Root '{{Quote(selected)}}')) {
throw 'The selected root alias was not accepted.'
}
New-Item -ItemType $linkType -Path '{{Quote(Path.Combine(target, "completions"))}}' -Target '{{Quote(outside)}}' -ErrorAction Stop | Out-Null
if (Test-CompletionPathWithinRoot -Path $destination -Root '{{Quote(selected)}}') {
throw 'A descendant link escaped the selected root.'
}
'bounded'
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("bounded", result.Output.Trim());
Assert.Empty(Directory.EnumerateFileSystemEntries(outside));
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task InstallCompletions_PreservesProfileAndSafelyActivates(bool dogfood, bool unicodeProfile)
{
using var env = new TestEnvironment();
var home = Path.Combine(env.MockHome, "it's $home ` [test]");
var cli = CreateFakeCli(home);
var profile = Path.Combine(home, "edition profile", "profile.ps1");
Directory.CreateDirectory(Path.GetDirectoryName(profile)!);
const string original = "# My existing profile without a trailing newline";
var encoding = unicodeProfile ? Encoding.Unicode : new UTF8Encoding(false);
File.WriteAllText(profile, original, encoding);
var originalBytes = File.ReadAllBytes(profile);
var completion = CompletionPath(home, cli, dogfood);
var invocation = $"Install-AspireCliCompletions -CliPath '{Quote(cli)}'" + (dogfood ? "" : " -Persist $true");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(home)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
function Test-ElevatedCompletionSession { $false }
{{invocation}}
$env:FAKE_COMPLETION_GENERATION = 'upgraded'
{{invocation}}
if (-not (Test-Path -LiteralPath '{{Quote(completion)}}')) { throw 'Missing completion file' }
{{(dogfood ? $". '{Quote(completion)}'" : $". '{Quote(profile)}'")}}
if ($global:AspireCompletionLoaded -ne 'upgraded') { throw 'Updated completion not sourced' }
Remove-Item -LiteralPath '{{Quote(completion)}}'
{{(dogfood ? "" : $". '{Quote(profile)}'")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var contents = File.ReadAllText(profile);
Assert.Equal(originalBytes, File.ReadAllBytes(profile)[..originalBytes.Length]);
Assert.Equal(dogfood ? 0 : 1, contents.Split("# Aspire CLI completions").Length - 1);
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
}
[Theory]
[InlineData(false, "fail", false)]
[InlineData(false, "empty", false)]
[InlineData(false, "skip", false)]
[InlineData(false, "whatif", false)]
[InlineData(true, "fail", false)]
[InlineData(true, "empty", false)]
[InlineData(true, "skip", false)]
[InlineData(true, "whatif", false)]
[InlineData(false, "fail", true)]
[InlineData(false, "empty", true)]
[InlineData(false, "skip", true)]
[InlineData(false, "whatif", true)]
[InlineData(true, "fail", true)]
[InlineData(true, "empty", true)]
[InlineData(true, "skip", true)]
[InlineData(true, "whatif", true)]
public async Task InstallCompletions_UnsuccessfulOrOptedOut_PreservesExistingFiles(bool dogfood, string mode, bool outsideHome)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(outsideHome ? Path.Combine(env.TempDirectory, "custom install") : env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
var completion = CompletionPath(env.MockHome, cli, dogfood || outsideHome);
Directory.CreateDirectory(Path.GetDirectoryName(completion)!);
File.WriteAllText(completion, "# working completions");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
$SkipPath = ${{(outsideHome ? "true" : "false")}}
$SkipCompletions = ${{(mode == "skip" ? "true" : "false")}}
$env:FAKE_COMPLETION_MODE = '{{mode}}'
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}} {{(mode == "whatif" ? "-WhatIf" : "")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# working completions", File.ReadAllText(completion));
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
if (mode is "skip" or "whatif")
{
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
}
else
{
Assert.Contains("CLI installation is unaffected", result.Output);
}
}
[Fact]
public async Task InstallCompletions_ProfileOutsideHome_IsNotModified()
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(Path.Combine(env.TempDirectory, "custom install"));
var profile = Path.Combine(Path.GetDirectoryName(cli)!, "outside-profile.ps1");
File.WriteAllText(profile, "# outside home");
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
function Test-ElevatedCompletionSession { $false }
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' -Persist $true
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# outside home", File.ReadAllText(profile));
Assert.True(File.Exists(CompletionPath(env.MockHome, cli, dogfood: false)));
Assert.False(Directory.Exists(Path.Combine(Path.GetDirectoryName(cli)!, "completions")));
Assert.Contains("CurrentUserAllHosts", result.Output);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task InstallCompletions_OutsideHomeInstall_GeneratesArtifactWithoutProfileMutation(bool dogfood, bool skipPath)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(Path.Combine(env.TempDirectory, "it's $install ` [test]"));
var profile = Path.Combine(env.MockHome, "profile.ps1");
File.WriteAllText(profile, "# existing profile");
var completion = CompletionPath(env.MockHome, cli, dogfood: true);
var invocation = $"Install-AspireCliCompletions -CliPath '{Quote(cli)}'" + (dogfood ? "" : " -Persist $true");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
$SkipPath = ${{(skipPath ? "true" : "false")}}
{{invocation}}
$env:FAKE_COMPLETION_GENERATION = 'upgraded'
{{invocation}}
. '{{Quote(completion)}}'
if ($global:AspireCompletionLoaded -ne 'upgraded') { throw 'Updated completion not sourced' }
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.True(File.Exists(completion));
Assert.False(Directory.Exists(Path.Combine(env.MockHome, ".aspire")));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
Assert.Contains("activate completions manually", result.Output);
}
[Theory]
[InlineData(false, "directory-link")]
[InlineData(false, "leaf-junction")]
[InlineData(false, "directory")]
[InlineData(true, "directory-link")]
[InlineData(true, "leaf-junction")]
[InlineData(true, "directory")]
public async Task InstallCompletions_OutsideHomeInstall_UnsafeDestinationIsNotModified(bool dogfood, string destination)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "custom install");
var cli = CreateFakeCli(installRoot);
var profile = Path.Combine(env.MockHome, "profile.ps1");
File.WriteAllText(profile, "# existing profile");
// A sibling with the same prefix must not be mistaken for a child of the install root.
var outside = Path.Combine(env.TempDirectory, "custom install-other");
Directory.CreateDirectory(outside);
var sentinel = Path.Combine(outside, "aspire.ps1");
File.WriteAllText(sentinel, "# outside completions");
var completion = CompletionPath(env.MockHome, cli, dogfood: true);
var link = destination == "directory-link" ? Path.GetDirectoryName(completion)! : completion;
Directory.CreateDirectory(Path.GetDirectoryName(link)!);
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
$SkipPath = $true
if ('{{destination}}' -eq 'directory') {
[IO.Directory]::CreateDirectory('{{Quote(link)}}') | Out-Null
} else {
# Junction creation does not require the Windows symbolic-link privilege.
$linkType = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }
New-Item -ItemType $linkType -Path '{{Quote(link)}}' -Target '{{Quote(outside)}}' -ErrorAction Stop | Out-Null
}
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.Equal("# outside completions", File.ReadAllText(sentinel));
Assert.Equal([sentinel], Directory.GetFiles(outside));
Assert.Empty(Directory.GetDirectories(outside));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
if (destination != "directory")
{
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
}
Assert.Contains("CLI installation is unaffected", result.Output);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_OutsideHomeInstall_WhatIfDoesNotCreateDirectories(bool dogfood)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "not yet installed", "bin");
var cli = Path.Combine(installRoot, "aspire.ps1");
var profile = Path.Combine(env.MockHome, "profile.ps1");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
$SkipPath = $true
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}} -WhatIf
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.False(Directory.Exists(Path.GetDirectoryName(installRoot)));
Assert.Contains("Generate PowerShell completions", result.Output);
}
[Theory]
[InlineData(false, 4)]
[InlineData(false, 5)]
[InlineData(false, 6)]
[InlineData(true, 4)]
[InlineData(true, 5)]
[InlineData(true, 6)]
public async Task InstallCompletions_PrePowerShell7_LeavesFilesUntouchedAndExplainsPwsh(bool dogfood, int majorVersion)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
var completion = CompletionPath(env.MockHome, cli, dogfood);
Directory.CreateDirectory(Path.GetDirectoryName(completion)!);
File.WriteAllText(completion, "# working completions");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
# Simulate the engine version in this isolated test process only.
$PSVersionTable.PSVersion = [Version]'{{majorVersion}}.0'
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# working completions", File.ReadAllText(completion));
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
Assert.Contains("require PowerShell 7 or later", result.Output);
Assert.Contains("Open pwsh, put aspire on PATH", result.Output);
Assert.Contains("aspire completions script pwsh", result.Output);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_SkipPath_GeneratesArtifactWithoutProfileMutation(bool dogfood)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
$SkipPath = $true
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.True(File.Exists(CompletionPath(env.MockHome, cli, dogfood: true)));
Assert.Contains("activate completions manually", result.Output);
}
[Theory]
[InlineData("signed")]
[InlineData("elevated")]
public async Task InstallCompletions_ProtectedProfile_IsNotModified(string protection)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
var original = protection == "signed" ? "# profile\n# SIG # Begin signature block\n# signature\n# SIG # End signature block\n" : "# profile";
File.WriteAllText(profile, original);
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
function Test-ElevatedCompletionSession { ${{(protection == "elevated" ? "true" : "false")}} }
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' -Persist $true
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(original, File.ReadAllText(profile));
Assert.True(File.Exists(CompletionPath(env.MockHome, cli, dogfood: false)));
Assert.Contains(protection == "signed" ? "Signed PowerShell profile left untouched" : "Elevated install", result.Output);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_AllSigned_PreservesFilesAndRequiresManualSigning(bool dogfood)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, "profile.ps1");
var completion = CompletionPath(env.MockHome, cli, dogfood);
Directory.CreateDirectory(Path.GetDirectoryName(completion)!);
File.WriteAllText(completion, "# existing signed completions");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRPowerShell : ScriptPaths.ReleasePowerShell,
$$"""
Set-Variable HOME '{{Quote(env.MockHome)}}' -Force
$PROFILE = @{ CurrentUserAllHosts = '{{Quote(profile)}}' }
function Get-ExecutionPolicy { 'AllSigned' }
Install-AspireCliCompletions -CliPath '{{Quote(cli)}}' {{(dogfood ? "" : "-Persist $true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing signed completions", File.ReadAllText(completion));
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
Assert.Contains("AllSigned execution policy", result.Output);
Assert.Contains("Generate and sign", result.Output);
}
private static string CreateFakeCli(string home)
{
Directory.CreateDirectory(home);
var cli = Path.Combine(home, "fake aspire.ps1");
File.WriteAllText(cli, """
if (($args -join ' ') -ne 'completions script pwsh') { exit 2 }
[IO.File]::WriteAllText((Join-Path $HOME 'called'), 'called')
if ($env:FAKE_COMPLETION_MODE -eq 'fail') { 'error output is not a script'; exit 1 }
if ($env:FAKE_COMPLETION_MODE -eq 'empty') { exit 0 }
$value = if ($env:FAKE_COMPLETION_GENERATION) { $env:FAKE_COMPLETION_GENERATION } else { 'loaded' }
'$global:AspireCompletionLoaded = ''' + $value + ''''
exit 0
""");
return cli;
}
private static string CompletionPath(string home, string cli, bool dogfood) =>
Path.Combine(dogfood ? Path.GetDirectoryName(cli)! : Path.Combine(home, ".aspire"), "completions", "aspire.ps1");
private static string Quote(string value) => value.Replace("'", "''");
}
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public class CompletionShellTests(ITestOutputHelper testOutput)
{
[Fact]
public async Task InstallCompletions_UnsafeSecondBashProfileDoesNotModifyFirst()
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var bashrc = Path.Combine(env.MockHome, ".bashrc");
var outside = Path.Combine(env.TempDirectory, "outside-profile");
File.WriteAllText(bashrc, "# existing bashrc");
File.WriteAllText(outside, "# outside profile");
File.CreateSymbolicLink(Path.Combine(env.MockHome, ".bash_profile"), outside);
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleaseShell,
$"SHELL=/bin/bash; install_completions '{Quote(cli)}' true",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Contains("CLI installation is unaffected", result.Output);
Assert.Equal("# existing bashrc", File.ReadAllText(bashrc));
Assert.Equal("# outside profile", File.ReadAllText(outside));
}
[Fact]
public async Task InstallCompletions_CrLfProfilesDoNotAccumulateRegistrations()
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var invocation = $"SHELL=/bin/bash; install_completions '{Quote(cli)}' true";
using (var command = new ScriptFunctionCommand(ScriptPaths.ReleaseShell, invocation, env, testOutput))
{
(await command.ExecuteAsync()).EnsureSuccessful();
}
var profiles = new[] { Path.Combine(env.MockHome, ".bashrc"), Path.Combine(env.MockHome, ".bash_profile") };
foreach (var profile in profiles)
{
File.WriteAllText(profile, File.ReadAllText(profile).ReplaceLineEndings("\r\n"));
}
var originalContents = profiles.Select(File.ReadAllBytes).ToArray();
using (var command = new ScriptFunctionCommand(ScriptPaths.ReleaseShell, invocation, env, testOutput))
{
(await command.ExecuteAsync()).EnsureSuccessful();
}
for (var i = 0; i < profiles.Length; i++)
{
Assert.Equal(originalContents[i], File.ReadAllBytes(profiles[i]));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletionBoundary_AcceptsFilesystemRootWithoutWritingToIt(bool dogfood)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
"is_completion_path_within_root /aspire-boundary-probe/completions/aspire.bash /",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletionBoundary_SelectedRootAliasIsAnchorButChildRedirectIsRejected(bool dogfood)
{
using var env = new TestEnvironment();
var target = Path.Combine(env.TempDirectory, "actual");
var selected = Path.Combine(env.TempDirectory, "selected");
var outside = Path.Combine(env.TempDirectory, "outside");
Directory.CreateDirectory(target);
Directory.CreateDirectory(outside);
Directory.CreateSymbolicLink(selected, target);
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
is_completion_path_within_root '{{Quote(Path.Combine(selected, "completions", "aspire.bash"))}}' '{{Quote(selected)}}'
ln -s '{{Quote(outside)}}' '{{Quote(Path.Combine(target, "completions"))}}'
if is_completion_path_within_root '{{Quote(Path.Combine(selected, "completions", "aspire.bash"))}}' '{{Quote(selected)}}'; then
echo 'A descendant link escaped the selected root.' >&2
exit 1
fi
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Empty(Directory.EnumerateFileSystemEntries(outside));
}
[Theory]
[InlineData(false, "bash")]
[InlineData(false, "zsh")]
[InlineData(false, "fish")]
[InlineData(true, "bash")]
[InlineData(true, "zsh")]
[InlineData(true, "fish")]
public async Task QuoteShellLiteral_PreservesQuotesBackslashesAndMetacharacters(bool dogfood, string shell)
{
using var env = new TestEnvironment();
const string value = "it's \\ $home ` [test] & \"quoted\"";
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$"quote_shell_literal '{Quote(value)}' {shell}",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var expected = shell == "fish"
? "'" + value.Replace("\\", "\\\\").Replace("'", "\\'") + "'"
: "'" + Quote(value) + "'";
Assert.Equal(expected, result.Output.Trim());
}
[Theory]
[InlineData(false, "bash")]
[InlineData(false, "zsh")]
[InlineData(false, "fish")]
[InlineData(true, "bash")]
[InlineData(true, "zsh")]
[InlineData(true, "fish")]
public async Task InstallCompletions_UsesUserShellPathsWithoutOverwritingProfile(bool dogfood, string shell)
{
using var env = new TestEnvironment();
var home = Path.Combine(env.MockHome, "it's $home ` [test]");
Directory.CreateDirectory(home);
var cli = CreateFakeCli(home);
var profile = shell switch
{
"zsh" => Path.Combine(home, "z dot", ".zshrc"),
"fish" => Path.Combine(home, "xdg config", "fish", "conf.d", "aspire-completions.fish"),
_ => Path.Combine(home, ".bashrc")
};
Directory.CreateDirectory(Path.GetDirectoryName(profile)!);
File.WriteAllText(profile, "# existing profile without trailing newline");
var completion = Path.Combine(dogfood ? home : Path.Combine(home, ".aspire"), "completions", $"aspire.{shell}");
var invocation = $"install_completions '{Quote(cli)}'" + (dogfood ? "" : " true");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
HOME='{{Quote(home)}}'
SHELL='/bin/{{shell}}'
ZDOTDIR="$HOME/z dot"
XDG_CONFIG_HOME="$HOME/xdg config"
{{invocation}}
export FAKE_COMPLETION_GENERATION=upgraded
{{invocation}}
test -s '{{Quote(completion)}}'
{{(dogfood || shell == "fish" ? "" : $"source '{Quote(profile)}'; test \"$ASPIRE_COMPLETION_LOADED\" = upgraded")}}
rm '{{Quote(completion)}}'
{{(dogfood || shell == "fish" ? "" : $"source '{Quote(profile)}'")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var contents = File.ReadAllText(profile);
Assert.StartsWith("# existing profile without trailing newline", contents);
Assert.Equal(dogfood ? 0 : 1, contents.Split("# Aspire CLI completions").Length - 1);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
}
[Theory]
[InlineData(false, "fail", false)]
[InlineData(false, "empty", false)]
[InlineData(false, "skip", false)]
[InlineData(false, "dryrun", false)]
[InlineData(false, "unsupported", false)]
[InlineData(false, "unset", false)]
[InlineData(true, "fail", false)]
[InlineData(true, "empty", false)]
[InlineData(true, "skip", false)]
[InlineData(true, "dryrun", false)]
[InlineData(true, "unsupported", false)]
[InlineData(true, "unset", false)]
[InlineData(false, "fail", true)]
[InlineData(false, "empty", true)]
[InlineData(false, "skip", true)]
[InlineData(false, "dryrun", true)]
[InlineData(false, "unsupported", true)]
[InlineData(false, "unset", true)]
[InlineData(true, "fail", true)]
[InlineData(true, "empty", true)]
[InlineData(true, "skip", true)]
[InlineData(true, "dryrun", true)]
[InlineData(true, "unsupported", true)]
[InlineData(true, "unset", true)]
public async Task InstallCompletions_UnsuccessfulOrOptedOut_PreservesExistingFiles(bool dogfood, string mode, bool outsideHome)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(outsideHome ? Path.Combine(env.TempDirectory, "custom install") : env.MockHome);
var completion = Path.Combine(dogfood || outsideHome ? Path.GetDirectoryName(cli)! : Path.Combine(env.MockHome, ".aspire"), "completions", "aspire.bash");
Directory.CreateDirectory(Path.GetDirectoryName(completion)!);
File.WriteAllText(completion, "# working completions");
var profile = Path.Combine(env.MockHome, ".bashrc");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL='{{(mode == "unsupported" ? "/bin/tcsh" : mode == "unset" ? "" : "/bin/bash")}}'
SKIP_PATH={{(outsideHome ? "true" : "false")}}
SKIP_COMPLETIONS={{(mode == "skip" ? "true" : "false")}}
DRY_RUN={{(mode == "dryrun" ? "true" : "false")}}
export FAKE_COMPLETION_MODE='{{mode}}'
install_completions '{{Quote(cli)}}' {{(dogfood ? "" : "true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# working completions", File.ReadAllText(completion));
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
if (mode is "skip" or "dryrun" or "unsupported" or "unset")
{
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_OutsideHomeProfileOrSymlink_IsNotModified(bool symlink)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "custom install");
var cli = CreateFakeCli(installRoot);
var outside = Path.Combine(installRoot, ".zshrc");
File.WriteAllText(outside, "# outside profile");
if (symlink)
{
File.CreateSymbolicLink(Path.Combine(env.MockHome, ".zshrc"), outside);
}
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/zsh
ZDOTDIR='{{Quote(symlink ? env.MockHome : installRoot)}}'
install_completions '{{Quote(cli)}}' true
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# outside profile", File.ReadAllText(outside));
Assert.True(File.Exists(Path.Combine(env.MockHome, ".aspire", "completions", "aspire.zsh")));
Assert.False(Directory.Exists(Path.Combine(installRoot, "completions")));
Assert.Contains("CLI installation is unaffected", result.Output);
}
[Theory]
[InlineData(false, true, "bash")]
[InlineData(false, true, "zsh")]
[InlineData(false, true, "fish")]
[InlineData(true, false, "bash")]
[InlineData(true, false, "zsh")]
[InlineData(true, false, "fish")]
[InlineData(true, true, "bash")]
public async Task InstallCompletions_OutsideHomeInstall_GeneratesArtifactWithoutProfileMutation(bool dogfood, bool skipPath, string shell)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "it's $install ` [test]");
var cli = CreateFakeCli(installRoot);
var profile = shell switch
{
"zsh" => Path.Combine(env.MockHome, ".zshrc"),
"fish" => Path.Combine(env.MockHome, ".config", "fish", "conf.d", "aspire-completions.fish"),
_ => Path.Combine(env.MockHome, ".bashrc")
};
Directory.CreateDirectory(Path.GetDirectoryName(profile)!);
File.WriteAllText(profile, "# existing profile");
var completion = Path.Combine(installRoot, "completions", $"aspire.{shell}");
var invocation = $"install_completions '{Quote(cli)}'" + (dogfood ? "" : " true");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL='/bin/{{shell}}'
SKIP_PATH={{(skipPath ? "true" : "false")}}
{{invocation}}
export FAKE_COMPLETION_GENERATION=upgraded
{{invocation}}
source '{{Quote(completion)}}'
test "$ASPIRE_COMPLETION_LOADED" = upgraded
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.True(File.Exists(completion));
Assert.False(Directory.Exists(Path.Combine(env.MockHome, ".aspire")));
Assert.False(File.Exists(Path.Combine(env.MockHome, ".bash_profile")));
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(completion)!, ".aspire-completions-*"));
Assert.Contains("activate completions manually", result.Output);
}
[Theory]
[InlineData(false, "directory-link")]
[InlineData(false, "file-link")]
[InlineData(false, "dangling-link")]
[InlineData(false, "directory")]
[InlineData(true, "directory-link")]
[InlineData(true, "file-link")]
[InlineData(true, "dangling-link")]
[InlineData(true, "directory")]
public async Task InstallCompletions_OutsideHomeInstall_UnsafeDestinationIsNotModified(bool dogfood, string destination)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "custom install");
var cli = CreateFakeCli(installRoot);
var profile = Path.Combine(env.MockHome, ".bashrc");
File.WriteAllText(profile, "# existing profile");
// A sibling with the same prefix must not be mistaken for a child of the install root.
var outside = Path.Combine(env.TempDirectory, "custom install-other");
Directory.CreateDirectory(outside);
var sentinel = Path.Combine(outside, "aspire.bash");
File.WriteAllText(sentinel, "# outside completions");
var completionDirectory = Path.Combine(installRoot, "completions");
var completion = Path.Combine(completionDirectory, "aspire.bash");
if (destination == "directory-link")
{
Directory.CreateSymbolicLink(completionDirectory, outside);
}
else
{
Directory.CreateDirectory(completionDirectory);
if (destination == "directory")
{
Directory.CreateDirectory(completion);
}
else
{
File.CreateSymbolicLink(completion, destination == "file-link" ? sentinel : Path.Combine(outside, "missing"));
}
}
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
SKIP_PATH=true
install_completions '{{Quote(cli)}}' {{(dogfood ? "" : "true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.Equal("# outside completions", File.ReadAllText(sentinel));
Assert.Equal([sentinel], Directory.GetFiles(outside));
Assert.Empty(Directory.GetDirectories(outside));
Assert.Empty(Directory.GetDirectories(completionDirectory, ".aspire-completions-*"));
Assert.False(File.Exists(Path.Combine(env.MockHome, "called")));
Assert.Contains("CLI installation is unaffected", result.Output);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_OutsideHomeInstall_DryRunDoesNotCreateDirectories(bool dogfood)
{
using var env = new TestEnvironment();
var installRoot = Path.Combine(env.TempDirectory, "not yet installed", "bin");
var cli = Path.Combine(installRoot, "aspire");
var profile = Path.Combine(env.MockHome, ".bashrc");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
SKIP_PATH=true
DRY_RUN=true
install_completions '{{Quote(cli)}}' {{(dogfood ? "" : "true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.False(Directory.Exists(Path.GetDirectoryName(installRoot)));
Assert.Contains("[DRY RUN] Would generate shell completions", result.Output);
}
[Theory]
[InlineData(".bash_profile")]
[InlineData(".bash_login")]
[InlineData(".profile")]
public async Task InstallCompletions_Bash_RegistersExistingLoginProfile(string loginProfile)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, loginProfile);
File.WriteAllText(profile, "# login profile");
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
install_completions '{{Quote(cli)}}' true
install_completions '{{Quote(cli)}}' true
source '{{Quote(profile)}}'
test "$ASPIRE_COMPLETION_LOADED" = loaded
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var content = File.ReadAllText(profile);
Assert.StartsWith("# login profile", content);
Assert.Equal(1, content.Split("# Aspire CLI completions").Length - 1);
Assert.True(File.Exists(Path.Combine(env.MockHome, ".bashrc")));
}
[Fact]
public async Task InstallCompletions_NewBashLoginProfile_PreservesPathSetup()
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
File.WriteAllText(Path.Combine(env.MockHome, ".bashrc"), "export ASPIRE_TEST_BASHRC_LOADED=true\n");
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
install_completions '{{Quote(cli)}}' true
source "$HOME/.bash_profile"
test "$ASPIRE_TEST_BASHRC_LOADED" = true
test "$ASPIRE_COMPLETION_LOADED" = loaded
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InstallCompletions_SkipPath_GeneratesArtifactWithoutProfileMutation(bool dogfood)
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, ".bashrc");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
dogfood ? ScriptPaths.PRShell : ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
SKIP_PATH=true
install_completions '{{Quote(cli)}}' {{(dogfood ? "" : "true")}}
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.True(File.Exists(Path.Combine(env.MockHome, "completions", "aspire.bash")));
Assert.False(File.Exists(Path.Combine(env.MockHome, ".bash_profile")));
Assert.Contains("activate completions manually", result.Output);
}
[Fact]
public async Task InstallCompletions_Elevated_DoesNotRegisterPathOrCompletions()
{
using var env = new TestEnvironment();
var cli = CreateFakeCli(env.MockHome);
var profile = Path.Combine(env.MockHome, ".bashrc");
File.WriteAllText(profile, "# existing profile");
using var cmd = new ScriptFunctionCommand(
ScriptPaths.ReleaseShell,
$$"""
set -euo pipefail
SHELL=/bin/bash
is_elevated_install() { return 0; }
add_to_shell_profile "$HOME/bin" '$HOME/bin'
install_completions '{{Quote(cli)}}' true
""",
env, testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("# existing profile", File.ReadAllText(profile));
Assert.False(File.Exists(Path.Combine(env.MockHome, ".bash_profile")));
Assert.True(File.Exists(Path.Combine(env.MockHome, ".aspire", "completions", "aspire.bash")));
Assert.Contains("Elevated install", result.Output);
}
private static string CreateFakeCli(string home)
{
Directory.CreateDirectory(home);
var cli = Path.Combine(home, "fake aspire");
File.WriteAllText(cli, ("""
#!/usr/bin/env bash
[[ "$1 $2" == "completions script" ]] || exit 2
printf called > "$HOME/called"
if [[ "${FAKE_COMPLETION_MODE:-}" == fail ]]; then echo 'error output is not a script'; exit 1; fi
if [[ "${FAKE_COMPLETION_MODE:-}" == empty ]]; then exit 0; fi
echo "ASPIRE_COMPLETION_LOADED=${FAKE_COMPLETION_GENERATION:-loaded}"
""" + "\n").ReplaceLineEndings("\n"));
FileHelper.MakeExecutable(cli);
return cli;
}
private static string Quote(string value) => value.Replace("'", "'\\''");
}