| File: Scripts\Common\ScriptFunctionCommand.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 Xunit; using Aspire.Templates.Tests; namespace Aspire.Acquisition.Tests.Scripts; /// <summary> /// Sources a script and calls an individual function, enabling unit-level testing of script internals. /// Uses a temporary wrapper script to avoid quoting and scope issues with inline commands. /// </summary> public class ScriptFunctionCommand : ToolCommand { private readonly string _scriptPath; private readonly string _functionExpression; private readonly TestEnvironment _testEnvironment; /// <summary> /// Creates a command that sources a script and calls a function. /// </summary> /// <param name="scriptPath">Relative path to the script from repo root.</param> /// <param name="functionExpression"> /// The full function call expression. /// For bash: <c>"construct_aspire_cli_url '' 'release' 'linux-x64' 'tar.gz'"</c> /// For PS1: <c>"Get-AspireCliUrl -Quality 'release' -RuntimeIdentifier 'linux-x64' -Extension 'tar.gz'"</c> /// </param> /// <param name="testEnvironment">Test environment providing isolated temp directories.</param> /// <param name="testOutput">xUnit test output helper.</param> public ScriptFunctionCommand( string scriptPath, string functionExpression, TestEnvironment testEnvironment, ITestOutputHelper testOutput) : base(GetExecutable(scriptPath), testOutput, label: $"{Path.GetFileName(scriptPath)}:func") { _scriptPath = scriptPath; _functionExpression = functionExpression; _testEnvironment = testEnvironment; WithEnvironmentVariable("HOME", _testEnvironment.MockHome); WithEnvironmentVariable("USERPROFILE", _testEnvironment.MockHome); // Override XDG_CONFIG_HOME to prevent scripts that consult // ${XDG_CONFIG_HOME:-$HOME/.config} from reading a real profile // outside the temp home when the developer has XDG_CONFIG_HOME set. WithEnvironmentVariable("XDG_CONFIG_HOME", Path.Combine(_testEnvironment.MockHome, ".config")); // Default timeout to prevent hanging tests — individual tests can override via WithTimeout() WithTimeout(TimeSpan.FromSeconds(60)); } private static string GetExecutable(string scriptPath) { return scriptPath.EndsWith(".sh", StringComparison.OrdinalIgnoreCase) ? "bash" : "pwsh"; } protected override string GetFullArgs(params string[] args) { var repoRoot = TestUtils.FindRepoRoot()?.FullName ?? throw new InvalidOperationException("Could not find repository root"); var fullScriptPath = Path.Combine(repoRoot, _scriptPath); if (!File.Exists(fullScriptPath)) { throw new FileNotFoundException($"Script not found: {fullScriptPath}"); } if (_scriptPath.EndsWith(".sh", StringComparison.OrdinalIgnoreCase)) { return BuildBashArgs(fullScriptPath); } else { return BuildPowerShellArgs(fullScriptPath); } } private string BuildBashArgs(string fullScriptPath) { // Write a temp bash script that sources the target and calls the function. // Sourcing works cleanly because bash scripts use a BASH_SOURCE guard that // skips main() when sourced from another script. // // We save/restore shell options and guard readonly variables so that: // - The sourced script's `set -euo pipefail` doesn't leak into the wrapper // - Re-sourcing the script doesn't fail on `readonly` redeclaration var tempScript = Path.Combine(_testEnvironment.TempDirectory, $"test-func-{Guid.NewGuid():N}.sh"); var wrapperContent = $$""" #!/bin/bash # Save current shell options _saved_opts=$(set +o) _saved_shopt=$(shopt -p 2>/dev/null || true) # Override the readonly builtin so that re-sourcing the script doesn't # fail on 'readonly VAR=value' when VAR was already declared readonly # in a previous source invocation. This suppresses ALL readonly errors — # not just redeclaration — but in practice the only readonly failures # in these scripts are redeclaration, and narrowing to that specific # case would require fragile stderr parsing across bash versions. readonly() { builtin readonly "$@" 2>/dev/null || true; } source "{{fullScriptPath}}" # Remove our readonly override unset -f readonly # Restore original shell options eval "$_saved_opts" 2>/dev/null || true eval "$_saved_shopt" 2>/dev/null || true {{_functionExpression}} """; File.WriteAllText(tempScript, wrapperContent); // Make executable on Unix FileHelper.MakeExecutable(tempScript); return $"\"{tempScript}\""; } private string BuildPowerShellArgs(string fullScriptPath) { // Use PowerShell's AST parser to extract only function definitions and // top-level variable assignments ($Script:* constants that functions depend on). // This avoids fragile string-based stripping of comment markers and control-flow // blocks, and works regardless of script formatting or structure. var tempScript = Path.Combine(_testEnvironment.TempDirectory, $"test-func-{Guid.NewGuid():N}.ps1"); // Escape single quotes in the path for PowerShell single-quoted strings var escapedScriptPath = fullScriptPath.Replace("'", "''"); var scriptContent = $$""" $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile( '{{escapedScriptPath}}', [ref]$tokens, [ref]$errors) if ($errors.Count -gt 0) { throw "Script has $($errors.Count) parse error(s): $($errors[0].Message)" } # Walk top-level statements directly. FindAll with a Parent check doesn't # work here because the Parent of top-level statements is the EndBlock # (NamedBlockAst), not the ScriptBlockAst. Extract only assignments and # function definitions — skip if/try blocks that contain main execution logic. foreach ($stmt in $ast.EndBlock.Statements) { if ($stmt -is [System.Management.Automation.Language.AssignmentStatementAst] -or $stmt -is [System.Management.Automation.Language.FunctionDefinitionAst]) { . ([ScriptBlock]::Create($stmt.Extent.Text)) } } {{_functionExpression}} """; File.WriteAllText(tempScript, scriptContent); return $"-NoProfile -File \"{tempScript}\""; } }