| File: Helpers\TerminalRun.cs | Web Access |
| Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.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 Hex1b.Automation; using Xunit; namespace Aspire.Cli.EndToEnd.Tests.Helpers; /// <summary> /// Wraps a terminal run session and ensures diagnostics are captured and the terminal is properly /// exited on disposal. Use via <see cref="CliE2ETestHelpers.StartRun"/> to consistently capture /// diagnostics at the end of every CLI E2E test. /// </summary> internal sealed class TerminalRun : IAsyncDisposable { private readonly Task _pendingRun; private readonly CancellationTokenSource _runCancellation; private readonly Hex1bTerminalAutomator _automator; private readonly SequenceCounter _counter; private readonly TemporaryWorkspace _workspace; private readonly ITestOutputHelper _output; internal TerminalRun(Task pendingRun, CancellationTokenSource runCancellation, Hex1bTerminalAutomator automator, SequenceCounter counter, TemporaryWorkspace workspace, ITestOutputHelper output) { _pendingRun = pendingRun; _runCancellation = runCancellation; _automator = automator; _counter = counter; _workspace = workspace; _output = output; } public async ValueTask DisposeAsync() { using var runCancellation = _runCancellation; // Capture diagnostics (best effort). The helper bounds its prompt wait; awaiting it directly // avoids abandoning an automator operation that could overlap with the exit input below. try { await _automator.CaptureAspireDiagnosticsAsync(_counter, _workspace); } catch (Exception ex) { WriteTestOutput($"[TerminalRun] Could not capture diagnostics before shutdown: {ex.Message}"); } // Exit the terminal (best effort) try { await _automator.TypeAsync("exit"); await _automator.EnterAsync(); } catch { // Best effort exit — the terminal may already be closed. } // A timed-out command can leave the shell busy, so the queued exit may never execute. // Cancel the terminal run after a grace period rather than hiding the original test failure. try { await WaitForExitAsync(_pendingRun, runCancellation, TimeSpan.FromSeconds(15)); } catch (Exception ex) { WriteTestOutput($"[TerminalRun] Terminal shutdown did not complete normally: {ex.Message}"); } // Copy workspace diagnostics to the host-side testresults directory so they appear // in CI artifacts. The in-Docker capture (CaptureAspireDiagnosticsAsync / EXIT trap) // writes files to the workspace volume mount, but that temp directory is not in the // CI-uploaded testresults/ path. This step bridges that gap. try { CaptureWorkspaceDiagnosticsToTestResults(); } catch { // Best effort — don't mask the original test failure. } } internal static async Task WaitForExitAsync(Task pendingRun, CancellationTokenSource runCancellation, TimeSpan timeout) { try { await pendingRun.WaitAsync(timeout); } catch (TimeoutException) { runCancellation.Cancel(); try { await pendingRun.WaitAsync(timeout); } catch (OperationCanceledException) when (runCancellation.IsCancellationRequested) { } catch (TimeoutException ex) { throw new TimeoutException( "The terminal did not exit after cancellation. Stopped waiting so the original test failure can be reported. " + "Check the terminal recording for the command that stopped making progress.", ex); } } } /// <summary> /// Copies the diagnostics directory from the workspace temp directory to the testresults path /// that CI uploads as artifacts. The in-Docker capture writes everything under a single /// <see cref="CliE2EAutomatorHelpers.DiagnosticsDirectoryName"/> subdirectory, so the host /// side only needs to copy that one directory. /// </summary> private void CaptureWorkspaceDiagnosticsToTestResults() { var diagnosticsSource = Path.Combine(_workspace.WorkspaceRoot.FullName, CliE2EAutomatorHelpers.DiagnosticsDirectoryName); if (!Directory.Exists(diagnosticsSource)) { WriteTestOutput($"[TerminalRun] No diagnostics directory found at: {diagnosticsSource}"); return; } var testName = TestContext.Current?.TestCase is { TestMethodName: { } methodName } ? methodName : "unknown"; var destDir = CliE2ETestHelpers.GetCaptureRootDirectory(testName); CopyDirectoryIfExists(diagnosticsSource, destDir); WriteTestOutput($"[TerminalRun] Captured diagnostics to: {destDir}"); WriteTestOutput($"[TerminalRun] Source workspace: {_workspace.WorkspaceRoot.FullName}"); // Report file counts per subdirectory so CI logs show what was actually captured. foreach (var subDir in Directory.GetDirectories(destDir)) { var fileCount = Directory.GetFiles(subDir, "*", SearchOption.AllDirectories).Length; WriteTestOutput($"[TerminalRun] {Path.GetFileName(subDir)}/: {fileCount} file(s)"); } // Count top-level files (e.g. aspire-start.json) var topLevelFiles = Directory.GetFiles(destDir); if (topLevelFiles.Length > 0) { WriteTestOutput($"[TerminalRun] (root): {topLevelFiles.Length} file(s)"); } } private static void CopyDirectoryIfExists(string source, string destination) { if (!Directory.Exists(source)) { return; } Directory.CreateDirectory(destination); foreach (var file in Directory.GetFiles(source)) { File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); } foreach (var dir in Directory.GetDirectories(source)) { CopyDirectoryIfExists(dir, Path.Combine(destination, Path.GetFileName(dir))); } } private void WriteTestOutput(string message) { _output.WriteLine(message); } }