|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable IDE0005 // Incorrectly flagged as unused due to types spread across namespaces
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
#pragma warning restore IDE0005
using Xunit;
namespace Aspire.Cli.EndToEndTests.Helpers;
/// <summary>
/// Helper methods for creating and managing Hex1b terminal sessions for Aspire CLI testing.
/// </summary>
internal static class CliE2ETestHelpers
{
/// <summary>
/// Gets whether the tests are running in CI (GitHub Actions) vs locally.
/// When running locally, some commands are replaced with echo stubs.
/// </summary>
internal static bool IsRunningInCI =>
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER")) &&
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA"));
/// <summary>
/// Gets the PR number from the GITHUB_PR_NUMBER environment variable.
/// When running locally (not in CI), returns a dummy value (0) for testing.
/// </summary>
/// <returns>The PR number, or 0 when running locally.</returns>
internal static int GetRequiredPrNumber()
{
var prNumberStr = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER");
if (string.IsNullOrEmpty(prNumberStr))
{
// Running locally - return dummy value
return 0;
}
Assert.True(int.TryParse(prNumberStr, out var prNumber), $"GITHUB_PR_NUMBER must be a valid integer, got: {prNumberStr}");
return prNumber;
}
/// <summary>
/// Gets the commit SHA from the GITHUB_PR_HEAD_SHA environment variable.
/// This is the actual PR head commit, not the merge commit (GITHUB_SHA).
/// When running locally (not in CI), returns a dummy value for testing.
/// </summary>
/// <returns>The commit SHA, or a dummy value when running locally.</returns>
internal static string GetRequiredCommitSha()
{
var commitSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA");
if (string.IsNullOrEmpty(commitSha))
{
// Running locally - return dummy value
return "local0000";
}
return commitSha;
}
/// <summary>
/// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts.
/// In CI, this returns a path under $GITHUB_WORKSPACE/testresults/recordings/.
/// Locally, this returns a path under the system temp directory.
/// </summary>
/// <param name="testName">The name of the test (used as the recording filename).</param>
/// <returns>The full path to the .cast recording file.</returns>
internal static string GetTestResultsRecordingPath(string testName)
{
var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE");
string recordingsDir;
if (!string.IsNullOrEmpty(githubWorkspace))
{
// CI environment - write directly to test results for artifact upload
recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings");
}
else
{
// Local development - use temp directory
recordingsDir = Path.Combine(Path.GetTempPath(), "aspire-cli-e2e", "recordings");
}
Directory.CreateDirectory(recordingsDir);
return Path.Combine(recordingsDir, $"{testName}.cast");
}
internal static Hex1bTerminalInputSequenceBuilder PrepareEnvironment(
this Hex1bTerminalInputSequenceBuilder builder, TemporaryWorkspace workspace, SequenceCounter counter)
{
var waitingForInputPattern = new CellPatternSearcher()
.Find("b").RightUntil("$").Right(' ').Right(' ');
builder.WaitUntil(s => waitingForInputPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
.Wait(500); // Small delay to ensure terminal is ready.
if (OperatingSystem.IsWindows())
{
// PowerShell prompt setup
const string promptSetup = "$global:CMDCOUNT=0; function prompt { $s=$?; $global:CMDCOUNT++; \"[$global:CMDCOUNT $(if($s){'OK'}else{\"ERR:$LASTEXITCODE\"})] PS> \" }";
builder.Type(promptSetup).Enter();
}
else
{
// Bash prompt setup
const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'";
builder.Type(promptSetup).Enter();
}
return builder.WaitForSuccessPrompt(counter)
.Type($"cd {workspace.WorkspaceRoot.FullName}").Enter()
.WaitForSuccessPrompt(counter);
}
internal static Hex1bTerminalInputSequenceBuilder InstallAspireCliFromPullRequest(
this Hex1bTerminalInputSequenceBuilder builder,
int prNumber,
SequenceCounter counter)
{
string command;
if (OperatingSystem.IsWindows())
{
// PowerShell installation command
command = $"iex \"& {{ $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) }} {prNumber}\"";
}
else
{
// Bash installation command
command = $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
}
return builder
.Type(command)
.Enter()
.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(300));
}
internal static Hex1bTerminalInputSequenceBuilder SourceAspireCliEnvironment(
this Hex1bTerminalInputSequenceBuilder builder,
SequenceCounter counter
)
{
if (OperatingSystem.IsWindows())
{
// On Windows, the PowerShell installer already updates the current session's PATH
// But we still need to set ASPIRE_PLAYGROUND for interactive mode and .NET CLI vars
return builder
.Type("$env:ASPIRE_PLAYGROUND='true'; $env:DOTNET_CLI_TELEMETRY_OPTOUT='true'; $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE='true'; $env:DOTNET_GENERATE_ASPNET_CERTIFICATE='false'")
.Enter()
.WaitForSuccessPrompt(counter);
}
// The installer adds aspire to ~/.aspire/bin
// We need to add it to PATH and set environment variables:
// - ASPIRE_PLAYGROUND=true enables interactive mode
// - .NET CLI vars suppress telemetry and first-time experience which can cause hangs
return builder
.Type("export PATH=~/.aspire/bin:$PATH ASPIRE_PLAYGROUND=true DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false")
.Enter()
.WaitForSuccessPrompt(counter);
}
/// <summary>
/// Verifies that the installed Aspire CLI version matches the expected commit SHA.
/// Runs 'aspire --version' and checks that the output contains the expected version suffix.
/// PR builds have version format: {version}-pr.{prNumber}.g{shortCommitSha}
/// </summary>
/// <param name="builder">The sequence builder.</param>
/// <param name="commitSha">The full 40-character commit SHA to verify against.</param>
/// <param name="counter">The sequence counter for prompt detection.</param>
/// <returns>The builder for chaining.</returns>
/// <exception cref="ArgumentException">Thrown when commitSha is not exactly 40 characters.</exception>
internal static Hex1bTerminalInputSequenceBuilder VerifyAspireCliVersion(
this Hex1bTerminalInputSequenceBuilder builder,
string commitSha,
SequenceCounter counter)
{
// Git SHA-1 hashes are exactly 40 hexadecimal characters
if (commitSha.Length != 40)
{
throw new ArgumentException($"Commit SHA must be exactly 40 characters, got {commitSha.Length}: '{commitSha}'", nameof(commitSha));
}
// PR builds use the format: {version}-pr.{prNumber}.g{shortCommitSha}
// The short commit SHA is 8 characters, prefixed with 'g' (git convention)
var shortCommitSha = commitSha[..8];
var expectedVersionSuffix = $"g{shortCommitSha}";
var versionPattern = new CellPatternSearcher()
.Find(expectedVersionSuffix);
return builder
.Type("aspire --version")
.Enter()
.WaitUntil(s => versionPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
.WaitForSuccessPrompt(counter);
}
}
|