Skip to content
Merged
40 changes: 32 additions & 8 deletions .agents/skills/cli-e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,53 @@ public sealed class SmokeTests(ITestOutputHelper output)
[Fact]
public async Task MyCliTest()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
var installMode = CliE2ETestHelpers.DetectDockerInstallMode();

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
await auto.InstallAspireCliAsync(strategy, counter);

await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
}
```

### TerminalRun Pattern

**Always use `CliE2ETestHelpers.StartRun`** to wrap the terminal run. This returns a `TerminalRun` (implements `IAsyncDisposable`) that automatically:
1. Captures Aspire diagnostics via `CaptureAspireDiagnosticsAsync` (best effort)
2. Types `exit` and presses Enter to close the terminal
3. Awaits the pending run task

This eliminates the need for manual `exit`/`await pendingRun` at the end of every test and ensures diagnostics are always captured, even when tests fail.

```csharp
// DO: Use StartRun for consistent diagnostics capture and cleanup
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

// ... test body — no exit/pendingRun needed at the end

// DON'T: Manually handle exit and pendingRun
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
// ... test body ...
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
```

## Running Tests Locally

CLI E2E tests run inside Docker containers on Linux. The workflow is: build a portable archive with `localhive`, then point the tests at it. This is the primary way to iterate on E2E tests during development.
Expand Down
8 changes: 1 addition & 7 deletions tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ public async Task CreateAndRunAspireStarterProjectWithBundle()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand All @@ -38,10 +37,5 @@ public async Task CreateAndRunAspireStarterProjectWithBundle()

await auto.AspireStartAsync(counter);
await auto.AspireStopAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
14 changes: 2 additions & 12 deletions tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand All @@ -52,10 +51,6 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate()
await auto.EnterAsync();
await auto.WaitUntilTextAsync("certificate is trusted", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[Fact]
Expand All @@ -67,10 +62,9 @@ public async Task CertificatesClean_RemovesCertificates()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand All @@ -95,10 +89,6 @@ public async Task CertificatesClean_RemovesCertificates()
await auto.EnterAsync();
await auto.WaitUntilTextAsync("No HTTPS development certificate", timeout: TimeSpan.FromSeconds(60));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[Fact]
Expand Down
8 changes: 1 addition & 7 deletions tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun()
mountDockerSocket: true,
workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand Down Expand Up @@ -97,10 +96,5 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun()
throw new InvalidOperationException(
$"Config file still contains invalid path after healing. Content:\n{content}");
}

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
7 changes: 1 addition & 6 deletions tests/Aspire.Cli.EndToEnd.Tests/DocsCommandE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ aspire docs get docs-smoke-test
""");

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand Down Expand Up @@ -90,10 +90,5 @@ await auto.WaitUntilAsync(snapshot =>
&& snapshot.ContainsText("Target Azure subscription");
}, timeout: TimeSpan.FromSeconds(60), description: "waiting for docs get rendered output");
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
17 changes: 17 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Xml.Linq;
using Aspire.Cli.Tests.Utils;
using Hex1b;
using Hex1b.Automation;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests.Helpers;
Expand Down Expand Up @@ -127,6 +128,22 @@ internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 4
.Build();
}

/// <summary>
/// Starts the terminal run and returns a <see cref="TerminalRun"/> that captures diagnostics
/// and exits the terminal on disposal.
/// </summary>
/// <param name="terminal">The Hex1b terminal to run.</param>
/// <param name="workspace">The workspace for diagnostic capture.</param>
/// <param name="automator">The automator used to drive the terminal.</param>
/// <param name="counter">The sequence counter for prompt tracking.</param>
/// <param name="cancellationToken">Cancellation token passed to <see cref="Hex1bTerminal.RunAsync"/>.</param>
/// <returns>A <see cref="TerminalRun"/> that ensures diagnostics capture and clean exit on disposal.</returns>
internal static TerminalRun StartRun(Hex1bTerminal terminal, TemporaryWorkspace workspace, Hex1bTerminalAutomator automator, SequenceCounter counter, CancellationToken cancellationToken)
{
var pendingRun = terminal.RunAsync(cancellationToken);
return new TerminalRun(pendingRun, automator, counter, workspace);
}

/// <summary>
/// Specifies which Dockerfile variant to use for the test container.
/// </summary>
Expand Down
62 changes: 62 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/TerminalRun.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;

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 Hex1bTerminalAutomator _automator;
private readonly SequenceCounter _counter;
private readonly TemporaryWorkspace _workspace;

internal TerminalRun(Task pendingRun, Hex1bTerminalAutomator automator, SequenceCounter counter, TemporaryWorkspace workspace)
{
_pendingRun = pendingRun;
_automator = automator;
_counter = counter;
_workspace = workspace;
}

public async ValueTask DisposeAsync()
{
// Capture diagnostics (best effort)
try
{
await _automator.CaptureAspireDiagnosticsAsync(_counter, _workspace);
}
catch
{
// Best effort diagnostics capture — don't mask the original test failure.
}

// Exit the terminal (best effort)
try
{
await _automator.TypeAsync("exit");
await _automator.EnterAsync();
}
catch
{
// Best effort exit — the terminal may already be closed.
}

// Wait for the terminal process to finish
try
{
await _pendingRun;
}
catch
{
// Best effort — if the test body threw, we don't want to mask it.
}
}
}
25 changes: 3 additions & 22 deletions tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ public async Task CreateAndRunAspireStarterProject()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

// Prepare Docker environment (prompt counting, umask, env vars)
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
Expand Down Expand Up @@ -63,12 +62,6 @@ await auto.WaitUntilAsync(s =>
// Stop the running apphost with Ctrl+C
await auto.Ctrl().KeyAsync(Hex1bKey.C);
await auto.WaitForSuccessPromptAsync(counter);

// Exit the shell
await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[CaptureWorkspaceOnFailure]
Expand All @@ -82,10 +75,9 @@ public async Task LatestCliCanStartStableChannelAppHost()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand All @@ -106,11 +98,6 @@ public async Task LatestCliCanStartStableChannelAppHost()
await auto.RunCommandFailFastAsync($"cd {projectName}", counter);
await auto.AspireStartAsync(counter);
await auto.AspireStopAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[CaptureWorkspaceOnFailure]
Expand All @@ -124,10 +111,9 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand All @@ -149,11 +135,6 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost()
await auto.RunCommandFailFastAsync($"cd {projectName}", counter);
await auto.AspireStartAsync(counter);
await auto.AspireStopAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

private static string GetAppHostSdkVersion(string appHostPath)
Expand Down
9 changes: 1 addition & 8 deletions tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,9 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

// Prepare Docker environment (prompt counting, umask, env vars)
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
Expand All @@ -122,12 +121,6 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully()
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

// Exit the shell
await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[Fact]
Expand Down
9 changes: 1 addition & 8 deletions tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ public async Task StopNonInteractiveSingleAppHost()

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
Expand Down Expand Up @@ -72,12 +71,6 @@ public async Task StopNonInteractiveSingleAppHost()
await auto.EnterAsync();
await auto.WaitUntilTextAsync(SharedCommandStrings.AppHostNotRunning, timeout: TimeSpan.FromSeconds(30));
await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));

// Exit the shell
await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[Fact]
Expand Down
Loading
Loading