| File: Helpers\DeploymentE2ETestHelpers.cs | Web Access |
| Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.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.Runtime.CompilerServices; using Hex1b; namespace Aspire.Deployment.EndToEnd.Tests.Helpers; /// <summary> /// Helper methods for creating and managing Hex1b terminal sessions for deployment testing. /// Extends the patterns from CLI E2E tests with deployment-specific functionality. /// </summary> internal static class DeploymentE2ETestHelpers { /// <summary> /// Gets whether the tests are running in CI (GitHub Actions) vs locally. /// </summary> internal static bool IsRunningInCI => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); /// <summary> /// Gets the PR number from the GITHUB_PR_NUMBER environment variable. /// When running locally (not in CI), returns 0. /// </summary> internal static int GetPrNumber() { var prNumberStr = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER"); if (string.IsNullOrEmpty(prNumberStr) || !int.TryParse(prNumberStr, out var prNumber)) { return 0; } return prNumber; } /// <summary> /// Gets the commit SHA from the GITHUB_PR_HEAD_SHA environment variable, /// falling back to GITHUB_SHA for non-PR CI runs (e.g., schedule-triggered workflows). /// When running locally (not in CI), returns "local0000". /// </summary> internal static string GetCommitSha() { var commitSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA"); if (!string.IsNullOrEmpty(commitSha)) { return commitSha; } var githubSha = Environment.GetEnvironmentVariable("GITHUB_SHA"); return string.IsNullOrEmpty(githubSha) ? "local0000" : githubSha; } /// <summary> /// Gets the GitHub Actions run ID from the GITHUB_RUN_ID environment variable. /// When running locally (not in CI), returns a timestamp-based ID. /// </summary> internal static string GetRunId() { var runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"); return string.IsNullOrEmpty(runId) ? DateTime.UtcNow.ToString("yyyyMMddHHmmss") : runId; } /// <summary> /// Gets the GitHub Actions run attempt from the GITHUB_RUN_ATTEMPT environment variable. /// When running locally (not in CI), returns "1". /// </summary> internal static string GetRunAttempt() { var runAttempt = Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT"); return string.IsNullOrEmpty(runAttempt) ? "1" : runAttempt; } /// <summary> /// Generates a unique resource group name for deployment tests. /// Format: e2e-[testcasename]-[runid]-[attempt] /// </summary> /// <param name="testCaseName">Short name for the test case (e.g., "starter", "python").</param> /// <returns>A unique resource group name.</returns> internal static string GenerateResourceGroupName(string testCaseName) { var runId = GetRunId(); var attempt = GetRunAttempt(); return $"e2e-{testCaseName}-{runId}-{attempt}"; } /// <summary> /// Gets the install strategy for exercising the current build under deployment E2E. /// CI uses the CLI already preinstalled by the workflow, while local runs honor explicit strategy overrides. /// </summary> internal static CliInstallStrategy GetCurrentBuildCliInstallStrategy() { return IsRunningInCI ? CliInstallStrategy.Preinstalled() : CliInstallStrategy.Detect(); } /// <summary> /// Creates a headless Hex1b terminal configured for deployment E2E testing with asciinema recording. /// Uses default dimensions of 160x48 unless overridden. /// </summary> /// <param name="testName">The test name used for the recording file path. Defaults to the calling method name.</param> /// <param name="width">The terminal width in columns. Defaults to 160.</param> /// <param name="height">The terminal height in rows. Defaults to 48.</param> /// <returns>A configured <see cref="Hex1bTerminal"/> instance. Caller is responsible for disposal.</returns> internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") { return Hex1bTestHelpers.CreateTestTerminal("aspire-deployment-e2e", width, height, testName); } /// <summary> /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// </summary> internal static string GetTestResultsRecordingPath(string testName) { return Hex1bTestHelpers.GetTestResultsRecordingPath(testName, "aspire-deployment-e2e"); } /// <summary> /// Gets the path for the GitHub step summary file. /// Returns null when not running in CI. /// </summary> internal static string? GetGitHubStepSummaryPath() { return Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); } /// <summary> /// Stops a detached AppHost out-of-band, without going through the test terminal. /// </summary> /// <remarks> /// Run-mode tests bind <c>terminal.RunAsync</c> to the test's cancellation token, so when the /// overall test timeout fires the terminal is gone and an <c>aspire stop</c> typed into it can /// never run — the detached AppHost would keep provisioning into a resource group that cleanup /// is about to delete. Launching the CLI directly keeps the stop working on that path, and gives /// it a timeout independent of the test budget. <c>StopCommand</c> locates the AppHost from the /// working directory rather than from terminal state, so running it out-of-band is equivalent. /// </remarks> internal static async Task StopAppHostAsync(string workspacePath, Action<string> log) { try { var cliPath = ResolveAspireCliPath(); if (cliPath is null) { log("Skipping AppHost stop: could not locate the aspire CLI."); return; } using var process = new Process { StartInfo = new ProcessStartInfo { FileName = cliPath, Arguments = "stop --non-interactive", WorkingDirectory = workspacePath, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } }; process.Start(); using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); try { await process.WaitForExitAsync(timeout.Token); log($"AppHost stop exited with code {process.ExitCode}."); } catch (OperationCanceledException) { // A hung stop must not block resource group cleanup, which is the step that actually // stops us paying for the deployment. process.Kill(entireProcessTree: true); log("AppHost stop timed out after 2 minutes and was killed."); } } catch (Exception ex) { log($"Failed to stop AppHost: {ex.Message}"); } } /// <summary> /// Locates the installed aspire CLI, checking the PR-isolated, current, and legacy install layouts. /// </summary> private static string? ResolveAspireCliPath() { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var candidates = new List<string>(); // The PR install route isolates the CLI under a per-PR directory rather than the shared bin dir: // compute_cli_install_dir() -> "$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER/bin" (INSTALL_PREFIX=$HOME/.aspire) // See eng/scripts/get-aspire-cli-pr.sh. CliInstallStrategy.Detect() selects that route whenever // GITHUB_PR_NUMBER and GITHUB_PR_HEAD_SHA are set, so probe it first: a PR-route machine may have // no CLI in the shared layout at all, and falling through would silently skip the AppHost stop. var prNumber = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER"); if (!string.IsNullOrEmpty(prNumber)) { candidates.Add(Path.Combine(home, ".aspire", "dogfood", $"pr-{prNumber}", "bin", "aspire")); } candidates.Add(Path.Combine(home, ".aspire", "bin", "aspire")); candidates.Add(Path.Combine(home, ".aspire", "aspire")); return candidates.Find(File.Exists); } }