diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md
index cf556871fa3..6e45aa8e87b 100644
--- a/.github/skills/cli-e2e-testing/SKILL.md
+++ b/.github/skills/cli-e2e-testing/SKILL.md
@@ -20,87 +20,51 @@ CLI E2E tests use the Hex1b library to automate terminal sessions, simulating re
### Core Classes
- **`Hex1bTerminal`**: The main terminal class from the Hex1b library for terminal automation
-- **`Hex1bTerminalInputSequenceBuilder`**: Fluent API for building sequences of terminal input/output operations
+- **`Hex1bTerminalAutomator`**: Async/await API for driving a `Hex1bTerminal` — the preferred approach for new tests
+- **`Hex1bAutomatorTestHelpers`** (shared helpers): Async extension methods on `Hex1bTerminalAutomator` (`WaitForSuccessPromptAsync`, `AspireNewAsync`, etc.)
+- **`CliE2EAutomatorHelpers`** (`Helpers/CliE2EAutomatorHelpers.cs`): CLI-specific async extension methods on `Hex1bTerminalAutomator` (`PrepareDockerEnvironmentAsync`, `InstallAspireCliInDockerAsync`, etc.)
- **`CellPatternSearcher`**: Pattern matching for terminal cell content
- **`SequenceCounter`** (`Helpers/SequenceCounter.cs`): Tracks command execution count for deterministic prompt detection
-- **`CliE2ETestHelpers`** (`Helpers/CliE2ETestHelpers.cs`): Extension methods and environment variable helpers
+- **`CliE2ETestHelpers`** (`Helpers/CliE2ETestHelpers.cs`): Environment variable helpers and terminal factory methods
- **`TemporaryWorkspace`**: Creates isolated temporary directories for test execution
+- **`Hex1bTerminalInputSequenceBuilder`** *(legacy)*: Fluent builder API for building sequences of terminal input/output operations. Prefer `Hex1bTerminalAutomator` for new tests.
### Test Architecture
Each test:
1. Creates a `TemporaryWorkspace` for isolation
2. Builds a `Hex1bTerminal` with headless mode and asciinema recording
-3. Creates a `Hex1bTerminalInputSequenceBuilder` with operations
-4. Applies the sequence to the terminal and awaits completion
+3. Creates a `Hex1bTerminalAutomator` wrapping the terminal
+4. Drives the terminal with async/await calls and awaits completion
## Test Structure
```csharp
-public sealed class SmokeTests : IAsyncDisposable
+public sealed class SmokeTests(ITestOutputHelper output)
{
- private readonly ITestOutputHelper _output;
- private readonly string _workDirectory;
-
- public SmokeTests(ITestOutputHelper output)
- {
- _output = output;
- _workDirectory = Path.Combine(Path.GetTempPath(), "aspire-cli-e2e", Guid.NewGuid().ToString("N")[..8]);
- Directory.CreateDirectory(_workDirectory);
- }
-
[Fact]
public async Task MyCliTest()
{
- var workspace = TemporaryWorkspace.Create(_output);
-
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
-
- using var terminal = CliE2ETestHelpers.CreateTestTerminal();
+ var workspace = TemporaryWorkspace.Create(output);
+ var installMode = CliE2ETestHelpers.DetectDockerInstallMode();
+ using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Define pattern searchers for expected output
- var waitingForExpectedOutput = new CellPatternSearcher()
- .Find("Expected output text");
-
- // Create a sequence counter for tracking command prompts
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- // Build the input sequence
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- if (isCI)
- {
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
- }
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
-
- public async ValueTask DisposeAsync()
- {
- // Clean up work directory
- if (Directory.Exists(_workDirectory))
- {
- Directory.Delete(_workDirectory, recursive: true);
- }
- await ValueTask.CompletedTask;
- }
}
```
@@ -110,22 +74,26 @@ The `SequenceCounter` class tracks the number of shell commands executed. This e
### How It Works
-1. `PrepareEnvironment()` configures the shell with a custom prompt: `[N OK] $ ` or `[N ERR:code] $ `
+1. `PrepareDockerEnvironmentAsync()` configures the shell with a custom prompt: `[N OK] $ ` or `[N ERR:code] $ `
2. Each command increments the counter
-3. `WaitForSuccessPrompt(counter)` waits for a prompt showing the current count with `OK`
+3. `WaitForSuccessPromptAsync(counter)` waits for a prompt showing the current count with `OK`
```csharp
var counter = new SequenceCounter();
+var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
-sequenceBuilder.PrepareEnvironment(workspace, counter) // Sets up prompt, counter starts at 1
- .Type("echo hello")
- .Enter()
- .WaitForSuccessPrompt(counter) // Waits for "[1 OK] $ ", then increments to 2
- .Type("ls -la")
- .Enter()
- .WaitForSuccessPrompt(counter) // Waits for "[2 OK] $ ", then increments to 3
- .Type("exit")
- .Enter();
+await auto.PrepareDockerEnvironmentAsync(counter, workspace); // Sets up prompt, counter starts at 1
+
+await auto.TypeAsync("echo hello");
+await auto.EnterAsync();
+await auto.WaitForSuccessPromptAsync(counter); // Waits for "[1 OK] $ ", then increments to 2
+
+await auto.TypeAsync("ls -la");
+await auto.EnterAsync();
+await auto.WaitForSuccessPromptAsync(counter); // Waits for "[2 OK] $ ", then increments to 3
+
+await auto.TypeAsync("exit");
+await auto.EnterAsync();
```
This approach is more reliable than arbitrary timeouts because it deterministically waits for each command to complete.
@@ -151,10 +119,11 @@ var waitingForAnyStarter = new CellPatternSearcher()
var waitingForShell = new CellPatternSearcher()
.Find("b").RightUntil("$").Right(' ').Right(' ');
-// Use in WaitUntil
-sequenceBuilder.WaitUntil(
+// Use in WaitUntilAsync
+await auto.WaitUntilAsync(
snapshot => waitingForPrompt.Search(snapshot).Count > 0,
- TimeSpan.FromSeconds(30));
+ TimeSpan.FromSeconds(30),
+ description: "waiting for prompt");
```
### Find vs FindPattern
@@ -166,29 +135,44 @@ sequenceBuilder.WaitUntil(
## Extension Methods
-### Hex1bTestHelpers Extensions (Shared)
+### Hex1bAutomatorTestHelpers Extensions (Shared — Automator API)
| Method | Description |
|--------|-------------|
-| `AspireNew(projectName, counter, template?, useRedisCache?)` | Runs `aspire new` interactively, handling template selection, project name, output path, URLs, Redis, and test project prompts |
+| `WaitForSuccessPromptAsync(counter, timeout?)` | Waits for `[N OK] $ ` prompt and increments counter |
+| `WaitForAnyPromptAsync(counter, timeout?)` | Waits for any prompt (`OK` or `ERR`) and increments counter |
+| `WaitForErrorPromptAsync(counter, timeout?)` | Waits for `[N ERR:code] $ ` prompt and increments counter |
+| `WaitForSuccessPromptFailFastAsync(counter, timeout?)` | Waits for success prompt, fails immediately if error prompt appears |
+| `DeclineAgentInitPromptAsync()` | Declines the `aspire agent init` prompt if it appears |
+| `AspireNewAsync(projectName, counter, template?, useRedisCache?)` | Runs `aspire new` interactively, handling template selection, project name, output path, URLs, Redis, and test project prompts |
See [AspireNew Helper](#aspirenew-helper) below for detailed usage.
-### CliE2ETestHelpers Extensions on Hex1bTerminalInputSequenceBuilder
+### CliE2EAutomatorHelpers Extensions on Hex1bTerminalAutomator
| Method | Description |
|--------|-------------|
-| `PrepareEnvironment(workspace, counter)` | Sets up custom prompt with command tracking, changes to workspace directory |
-| `InstallAspireCliFromPullRequest(prNumber, counter)` | Downloads and installs CLI from PR artifacts |
-| `SourceAspireCliEnvironment(counter)` | Adds `~/.aspire/bin` to PATH (Linux only) |
+| `PrepareDockerEnvironmentAsync(counter, workspace)` | Sets up Docker container environment with custom prompt and command tracking |
+| `InstallAspireCliInDockerAsync(installMode, counter)` | Installs the Aspire CLI inside the Docker container |
+| `ClearScreenAsync(counter)` | Clears the terminal screen and waits for prompt |
### SequenceCounterExtensions
| Method | Description |
|--------|-------------|
-| `WaitForSuccessPrompt(counter, timeout?)` | Waits for `[N OK] $ ` prompt and increments counter |
| `IncrementSequence(counter)` | Manually increments the counter |
+### Legacy Builder Extensions
+
+The following extensions on `Hex1bTerminalInputSequenceBuilder` are still available but should not be used in new tests:
+
+| Method | Description |
+|--------|-------------|
+| `WaitForSuccessPrompt(counter, timeout?)` | *(legacy)* Waits for `[N OK] $ ` prompt and increments counter |
+| `PrepareEnvironment(workspace, counter)` | *(legacy)* Sets up custom prompt with command tracking |
+| `InstallAspireCliFromPullRequest(prNumber, counter)` | *(legacy)* Downloads and installs CLI from PR artifacts |
+| `SourceAspireCliEnvironment(counter)` | *(legacy)* Adds `~/.aspire/bin` to PATH |
+
## DO: Use CellPatternSearcher for Output Detection
Wait for specific output patterns rather than arbitrary delays:
@@ -197,24 +181,26 @@ Wait for specific output patterns rather than arbitrary delays:
var waitingForMessage = new CellPatternSearcher()
.Find("Project created successfully.");
-sequenceBuilder
- .Type("aspire new")
- .Enter()
- .WaitUntil(s => waitingForMessage.Search(s).Count > 0, TimeSpan.FromMinutes(2));
+await auto.TypeAsync("aspire new");
+await auto.EnterAsync();
+await auto.WaitUntilAsync(
+ s => waitingForMessage.Search(s).Count > 0,
+ TimeSpan.FromMinutes(2),
+ description: "waiting for project created message");
```
-## DO: Use WaitForSuccessPrompt After Commands
+## DO: Use WaitForSuccessPromptAsync After Commands
-After running shell commands, use `WaitForSuccessPrompt()` to wait for the command to complete:
+After running shell commands, use `WaitForSuccessPromptAsync()` to wait for the command to complete:
```csharp
-sequenceBuilder
- .Type("dotnet build")
- .Enter()
- .WaitForSuccessPrompt(counter) // Waits for prompt, verifies success
- .Type("dotnet run")
- .Enter()
- .WaitForSuccessPrompt(counter);
+await auto.TypeAsync("dotnet build");
+await auto.EnterAsync();
+await auto.WaitForSuccessPromptAsync(counter); // Waits for prompt, verifies success
+
+await auto.TypeAsync("dotnet run");
+await auto.EnterAsync();
+await auto.WaitForSuccessPromptAsync(counter);
```
## AspireNew Helper
@@ -244,44 +230,49 @@ The `AspireNew` extension method centralizes the multi-step `aspire new` interac
```csharp
// Starter template with defaults (Redis=Yes, TestProject=No)
-sequenceBuilder.AspireNew("MyProject", counter);
+await auto.AspireNewAsync("MyProject", counter);
// Starter template, no Redis
-sequenceBuilder.AspireNew("MyProject", counter, useRedisCache: false);
+await auto.AspireNewAsync("MyProject", counter, useRedisCache: false);
// JsReact template, no Redis
-sequenceBuilder.AspireNew("MyProject", counter, template: AspireTemplate.JsReact, useRedisCache: false);
+await auto.AspireNewAsync("MyProject", counter, template: AspireTemplate.JsReact, useRedisCache: false);
// PythonReact template
-sequenceBuilder.AspireNew("MyProject", counter,
+await auto.AspireNewAsync("MyProject", counter,
template: AspireTemplate.PythonReact,
useRedisCache: false);
// Empty app host
-sequenceBuilder.AspireNew("MyProject", counter, template: AspireTemplate.EmptyAppHost);
+await auto.AspireNewAsync("MyProject", counter, template: AspireTemplate.EmptyAppHost);
```
## DO: Handle Interactive Prompts
-For `aspire new`, use the `AspireNew` helper instead of manually building the prompt sequence:
+For `aspire new`, use the `AspireNewAsync` helper instead of manually building the prompt sequence:
```csharp
// DO: Use the helper
-sequenceBuilder.AspireNew("MyProject", counter);
+await auto.AspireNewAsync("MyProject", counter);
-// DON'T: Manually build the sequence (this is what AspireNew does internally)
+// DON'T: Manually build the sequence (this is what AspireNewAsync does internally)
var waitingForTemplatePrompt = new CellPatternSearcher()
.FindPattern("> Starter App");
var waitingForProjectNamePrompt = new CellPatternSearcher()
.Find("Enter the project name");
-sequenceBuilder
- .Type("aspire new")
- .Enter()
- .WaitUntil(s => waitingForTemplatePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Enter()
- .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .Type("MyProject")
- .Enter();
+await auto.TypeAsync("aspire new");
+await auto.EnterAsync();
+await auto.WaitUntilAsync(
+ s => waitingForTemplatePrompt.Search(s).Count > 0,
+ TimeSpan.FromSeconds(30),
+ description: "waiting for template prompt");
+await auto.EnterAsync();
+await auto.WaitUntilAsync(
+ s => waitingForProjectNamePrompt.Search(s).Count > 0,
+ TimeSpan.FromSeconds(10),
+ description: "waiting for project name prompt");
+await auto.TypeAsync("MyProject");
+await auto.EnterAsync();
```
For other interactive CLI commands, wait for each prompt before responding:
@@ -290,11 +281,13 @@ For other interactive CLI commands, wait for each prompt before responding:
var waitingForPrompt = new CellPatternSearcher()
.Find("Enter your choice");
-sequenceBuilder
- .Type("aspire some-command")
- .Enter()
- .WaitUntil(s => waitingForPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Enter();
+await auto.TypeAsync("aspire some-command");
+await auto.EnterAsync();
+await auto.WaitUntilAsync(
+ s => waitingForPrompt.Search(s).Count > 0,
+ TimeSpan.FromSeconds(30),
+ description: "waiting for choice prompt");
+await auto.EnterAsync();
```
## DO: Use Ctrl+C to Stop Long-Running Processes
@@ -302,12 +295,16 @@ sequenceBuilder
For processes like `aspire run` that don't exit on their own:
```csharp
-sequenceBuilder
- .Type("aspire run")
- .Enter()
- .WaitUntil(s => waitForCtrlCMessage.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Ctrl().Key(Hex1bKey.C) // Send Ctrl+C
- .WaitForSuccessPrompt(counter);
+using Hex1b.Input;
+
+await auto.TypeAsync("aspire run");
+await auto.EnterAsync();
+await auto.WaitUntilAsync(
+ s => waitForCtrlCMessage.Search(s).Count > 0,
+ TimeSpan.FromSeconds(30),
+ description: "waiting for Ctrl+C message");
+await auto.Ctrl().KeyAsync(Hex1bKey.C); // Send Ctrl+C
+await auto.WaitForSuccessPromptAsync(counter);
```
## DO: Check IsRunningInCI for CI-Only Operations
@@ -315,15 +312,10 @@ sequenceBuilder
Some operations only apply in CI (like installing CLI from PR artifacts):
```csharp
-var isCI = CliE2ETestHelpers.IsRunningInCI;
-
-sequenceBuilder.PrepareEnvironment(workspace, counter);
+var installMode = CliE2ETestHelpers.DetectDockerInstallMode();
-if (isCI)
-{
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
-}
+await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Continue with test commands...
```
@@ -338,30 +330,68 @@ var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); // GITHUB_PR_HEAD_SHA
var isCI = CliE2ETestHelpers.IsRunningInCI; // true when both env vars set
```
+## DO: Always Include `description:` on WaitUntilAsync
+
+Every `WaitUntilAsync` call requires a named `description:` parameter. This description appears in logs and asciinema recordings to make debugging easier when a wait times out.
+
+```csharp
+// DON'T: Missing description
+await auto.WaitUntilAsync(
+ s => pattern.Search(s).Count > 0,
+ TimeSpan.FromSeconds(30));
+
+// DO: Include a meaningful description
+await auto.WaitUntilAsync(
+ s => pattern.Search(s).Count > 0,
+ TimeSpan.FromSeconds(30),
+ description: "waiting for build output");
+```
+
+## DO: Inline Code Where `ExecuteCallback` Was Used
+
+The old builder API used `ExecuteCallback()` to run synchronous operations mid-sequence. With the automator API, simply inline the code directly — no special wrapper is needed.
+
+```csharp
+// Old builder API (DON'T use in new tests)
+sequenceBuilder
+ .ExecuteCallback(() => File.WriteAllText(configPath, newConfig))
+ .Type("aspire run")
+ .Enter();
+
+// Automator API (DO)
+File.WriteAllText(configPath, newConfig);
+await auto.TypeAsync("aspire run");
+await auto.EnterAsync();
+```
+
## DON'T: Use Hard-coded Delays
-Use `WaitUntil()` with specific output patterns instead of arbitrary delays:
+Use `WaitUntilAsync()` with specific output patterns instead of arbitrary delays:
```csharp
// DON'T: Arbitrary delays
-.Wait(TimeSpan.FromSeconds(30))
+await Task.Delay(TimeSpan.FromSeconds(30));
// DO: Wait for specific output
-.WaitUntil(
+await auto.WaitUntilAsync(
snapshot => pattern.Search(snapshot).Count > 0,
- TimeSpan.FromSeconds(30))
+ TimeSpan.FromSeconds(30),
+ description: "waiting for expected output");
```
## DON'T: Hard-code Prompt Sequence Numbers
-Don't hard-code the sequence numbers in `WaitForSuccessPrompt` calls. Use the counter:
+Don't hard-code the sequence numbers in `WaitForSuccessPromptAsync` calls. Use the counter:
```csharp
// DON'T: Hard-coded sequence numbers
-.WaitUntil(s => s.GetScreenText().Contains("[3 OK] $ "), timeout)
+await auto.WaitUntilAsync(
+ s => s.GetScreenText().Contains("[3 OK] $ "),
+ timeout,
+ description: "waiting for prompt");
// DO: Use the counter
-.WaitForSuccessPrompt(counter)
+await auto.WaitForSuccessPromptAsync(counter);
```
The counter automatically tracks which command you're waiting for, even if command sequences change.
@@ -405,11 +435,11 @@ This reveals the exact strings like:
## Adding New Extension Methods
-When adding new CLI operations as extension methods:
+When adding new CLI operations as extension methods, define them on `Hex1bTerminalAutomator`:
```csharp
-internal static Hex1bTerminalInputSequenceBuilder MyNewOperation(
- this Hex1bTerminalInputSequenceBuilder builder,
+internal static async Task MyNewOperationAsync(
+ this Hex1bTerminalAutomator auto,
string arg,
SequenceCounter counter,
TimeSpan? timeout = null)
@@ -417,22 +447,23 @@ internal static Hex1bTerminalInputSequenceBuilder MyNewOperation(
var expectedOutput = new CellPatternSearcher()
.Find("Expected output");
- return builder
- .Type($"aspire my-command {arg}")
- .Enter()
- .WaitUntil(
- snapshot => expectedOutput.Search(snapshot).Count > 0,
- timeout ?? TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"aspire my-command {arg}");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ snapshot => expectedOutput.Search(snapshot).Count > 0,
+ timeout ?? TimeSpan.FromSeconds(30),
+ description: "waiting for expected output from my-command");
+ await auto.WaitForSuccessPromptAsync(counter);
}
```
Key points:
-1. Define as extension method on `Hex1bTerminalInputSequenceBuilder`
+1. Define as async extension method on `Hex1bTerminalAutomator`
2. Accept `SequenceCounter` parameter for prompt tracking
3. Use `CellPatternSearcher` for output detection
-4. Call `WaitForSuccessPrompt(counter)` after command completion
-5. Return the builder for fluent chaining
+4. Always include `description:` on `WaitUntilAsync` calls
+5. Call `WaitForSuccessPromptAsync(counter)` after command completion
+6. Return `Task` (no fluent chaining needed with async/await)
## CI Configuration
diff --git a/.github/skills/deployment-e2e-testing/SKILL.md b/.github/skills/deployment-e2e-testing/SKILL.md
index 71687976658..1e5868fba23 100644
--- a/.github/skills/deployment-e2e-testing/SKILL.md
+++ b/.github/skills/deployment-e2e-testing/SKILL.md
@@ -39,7 +39,9 @@ Key differences from CLI E2E tests:
### Core Classes
-- **`DeploymentE2ETestHelpers`** (`Helpers/DeploymentE2ETestHelpers.cs`): Terminal automation helpers
+- **`DeploymentE2ETestHelpers`** (`Helpers/DeploymentE2ETestHelpers.cs`): Terminal factory and environment helpers
+- **`DeploymentE2EAutomatorHelpers`** (`Helpers/DeploymentE2EAutomatorHelpers.cs`): Async extension methods on `Hex1bTerminalAutomator` for deployment scenarios
+- **`Hex1bAutomatorTestHelpers`** (shared): Common async extension methods on `Hex1bTerminalAutomator` (`WaitForSuccessPromptAsync`, `AspireNewAsync`, etc.)
- **`AzureAuthenticationHelpers`** (`Helpers/AzureAuthenticationHelpers.cs`): Azure auth and resource naming
- **`DeploymentReporter`** (`Helpers/DeploymentReporter.cs`): GitHub step summary reporting
- **`SequenceCounter`** (`Helpers/SequenceCounter.cs`): Prompt tracking (same as CLI E2E)
@@ -92,14 +94,18 @@ public sealed class MyDeploymentTests(ITestOutputHelper output)
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- // Build sequence: prepare, create project, deploy, verify
- sequenceBuilder.PrepareEnvironment(workspace, counter);
- // ... add deployment steps ...
+ await auto.PrepareEnvironmentAsync(workspace, counter);
+ if (DeploymentE2ETestHelpers.IsRunningInCI)
+ {
+ await auto.SourceAspireCliEnvironmentAsync(counter);
+ }
+ await auto.AspireNewAsync("MyProject", counter, useRedisCache: false);
+ // ... deployment steps ...
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
// 4. Report success
@@ -127,6 +133,19 @@ public sealed class MyDeploymentTests(ITestOutputHelper output)
}
```
+## Extension Methods
+
+### DeploymentE2EAutomatorHelpers Extensions on Hex1bTerminalAutomator
+
+| Method | Description |
+|--------|-------------|
+| `PrepareEnvironmentAsync(workspace, counter)` | Sets up the terminal environment with custom prompt and workspace directory |
+| `InstallAspireCliFromPullRequestAsync(prNumber, counter)` | Downloads and installs the Aspire CLI from a PR build artifact |
+| `InstallAspireCliReleaseAsync(counter)` | Installs the latest released Aspire CLI |
+| `SourceAspireCliEnvironmentAsync(counter)` | Adds `~/.aspire/bin` to PATH so the `aspire` command is available |
+
+These extend `Hex1bTerminalAutomator` and are used alongside the shared `Hex1bAutomatorTestHelpers` methods (`WaitForSuccessPromptAsync`, `AspireNewAsync`, etc.) documented in the [CLI E2E Testing Skill](../cli-e2e-testing/SKILL.md).
+
## Azure Authentication
### In CI (GitHub Actions)
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs
index 3b657da9596..39ed7dc2a97 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs
@@ -33,75 +33,49 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Patterns for aspire agent --help
- var agentMcpSubcommand = new CellPatternSearcher().Find("mcp");
- var agentInitSubcommand = new CellPatternSearcher().Find("init");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Test 1: aspire agent --help
- sequenceBuilder
- .Type("aspire agent --help")
- .Enter()
- .WaitUntil(s =>
- {
- var hasMcp = agentMcpSubcommand.Search(s).Count > 0;
- var hasInit = agentInitSubcommand.Search(s).Count > 0;
- return hasMcp && hasInit;
- }, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire agent --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("mcp") && s.ContainsText("init"),
+ timeout: TimeSpan.FromSeconds(30), description: "agent help showing mcp and init subcommands");
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 2: aspire agent mcp --help
- // Using a more specific pattern that won't match later outputs
- var mcpHelpPattern = new CellPatternSearcher().Find("aspire agent mcp [options]");
- sequenceBuilder
- .Type("aspire agent mcp --help")
- .Enter()
- .WaitUntil(s => mcpHelpPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire agent mcp --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("aspire agent mcp [options]", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 3: aspire agent init --help
- var initHelpPattern = new CellPatternSearcher().Find("aspire agent init [options]");
- sequenceBuilder
- .Type("aspire agent init --help")
- .Enter()
- .WaitUntil(s => initHelpPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire agent init --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("aspire agent init [options]", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 4: aspire mcp --help (now shows tools and call subcommands)
- var mcpToolsSubcommand = new CellPatternSearcher().Find("tools");
- var mcpCallSubcommand = new CellPatternSearcher().Find("call");
- sequenceBuilder
- .Type("aspire mcp --help")
- .Enter()
- .WaitUntil(s =>
- {
- var hasTools = mcpToolsSubcommand.Search(s).Count > 0;
- var hasCall = mcpCallSubcommand.Search(s).Count > 0;
- return hasTools && hasCall;
- }, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire mcp --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("tools") && s.ContainsText("call"),
+ timeout: TimeSpan.FromSeconds(30), description: "mcp help showing tools and call subcommands");
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 5: aspire mcp tools --help
- var mcpToolsHelpPattern = new CellPatternSearcher().Find("aspire mcp tools [options]");
- sequenceBuilder
- .Type("aspire mcp tools --help")
- .Enter()
- .WaitUntil(s => mcpToolsHelpPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("aspire mcp tools --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("aspire mcp tools [options]", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -126,90 +100,60 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig()
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json");
var containerConfigPath = CliE2ETestHelpers.ToContainerPath(configPath, workspace);
- // Patterns for agent init prompts - look for the colon at the end which indicates
- // the prompt is ready for input
- var workspacePathPrompt = new CellPatternSearcher().Find("workspace:");
-
- // Pattern to detect if no environments are found
- var noEnvironmentsMessage = new CellPatternSearcher().Find("No agent environments");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Create deprecated config file using Claude Code format (.mcp.json)
// This simulates a config that was created by an older version of the CLI
// Using single-line JSON to avoid any whitespace parsing issues
- sequenceBuilder
- .CreateDeprecatedMcpConfig(configPath);
+ File.WriteAllText(configPath, """{"mcpServers":{"aspire":{"command":"aspire","args":["mcp","start"]}}}""");
// Verify the deprecated config was created
- sequenceBuilder
- .VerifyFileContains(configPath, "\"mcp\"")
- .VerifyFileContains(configPath, "\"start\"");
+ var fileContent = File.ReadAllText(configPath);
+ Assert.Contains("\"mcp\"", fileContent);
+ Assert.Contains("\"start\"", fileContent);
// Debug: Show that the file exists and where we are
- var fileExistsPattern = new CellPatternSearcher().Find(".mcp.json");
- sequenceBuilder
- .Type($"ls -la {containerConfigPath} && pwd")
- .Enter()
- .WaitUntil(s => fileExistsPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"ls -la {containerConfigPath} && pwd");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync(".mcp.json", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Run aspire agent init - should detect and auto-migrate deprecated config
// In the new flow, deprecated config migrations are applied silently
- var configurePrompt = new CellPatternSearcher().Find("configure");
- var configComplete = new CellPatternSearcher().Find("omplete");
-
- sequenceBuilder
- .Type("aspire agent init")
- .Enter()
- .WaitUntil(s => workspacePathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Wait(500) // Small delay to ensure prompt is ready
- .Enter() // Accept default workspace path
- .WaitUntil(s =>
- {
- // Migration happens silently. We'll see either:
- // - The configure prompt (if other environments were detected)
- // - "Configuration complete" (if only deprecated configs were found)
- // - "No agent environments" (if nothing was found)
- var hasConfigure = configurePrompt.Search(s).Count > 0;
- var hasNoEnv = noEnvironmentsMessage.Search(s).Count > 0;
- var hasComplete = configComplete.Search(s).Count > 0;
- return hasConfigure || hasNoEnv || hasComplete;
- }, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("aspire agent init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitAsync(500); // Small delay to ensure prompt is ready
+ await auto.EnterAsync(); // Accept default workspace path
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("configure") || s.ContainsText("No agent environments") || s.ContainsText("omplete"),
+ timeout: TimeSpan.FromSeconds(60), description: "configure prompt, completion, or no environments message");
// If we got the configure prompt, just press Enter to accept defaults
// If we got complete/no-env, this Enter is harmless
- sequenceBuilder
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Debug: Show the scanner log file to diagnose what the scanner found
- var debugLogPattern = new CellPatternSearcher().Find("Scanning context");
- sequenceBuilder
- .Type("cat /tmp/aspire-deprecated-scan.log 2>/dev/null || echo 'No debug log found'")
- .Enter()
- .WaitUntil(s => debugLogPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat /tmp/aspire-deprecated-scan.log 2>/dev/null || echo 'No debug log found'");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Scanning context", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Verify config was updated to new format
// The updated config should contain "agent" and "mcp" but not "start"
- sequenceBuilder
- .VerifyFileContains(configPath, "\"agent\"")
- .VerifyFileContains(configPath, "\"mcp\"")
- .VerifyFileDoesNotContain(configPath, "\"start\"");
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
+ fileContent = File.ReadAllText(configPath);
+ Assert.Contains("\"agent\"", fileContent);
+ Assert.Contains("\"mcp\"", fileContent);
+ Assert.DoesNotContain("\"start\"", fileContent);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -230,43 +174,24 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig()
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json");
- // Pattern to detect deprecated config warning in doctor output
- var deprecatedWarning = new CellPatternSearcher().Find("deprecated");
-
- // Pattern to detect fix suggestion
- var fixSuggestion = new CellPatternSearcher().Find("aspire agent init");
-
- // Pattern to detect doctor completion
- var doctorComplete = new CellPatternSearcher().Find("dev-certs");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create deprecated config file
- sequenceBuilder
- .CreateDeprecatedMcpConfig(configPath)
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s =>
- {
- var hasComplete = doctorComplete.Search(s).Count > 0;
- var hasDeprecated = deprecatedWarning.Search(s).Count > 0;
- var hasFix = fixSuggestion.Search(s).Count > 0;
- return hasComplete && hasDeprecated && hasFix;
- }, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ File.WriteAllText(configPath, """{"mcpServers":{"aspire":{"command":"aspire","args":["mcp","start"]}}}""");
+ await auto.TypeAsync("aspire doctor");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("dev-certs") && s.ContainsText("deprecated") && s.ContainsText("aspire agent init"),
+ timeout: TimeSpan.FromSeconds(60), description: "doctor output with deprecated warning and fix suggestion");
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -290,47 +215,36 @@ public async Task AgentInitCommand_DefaultSelection_InstallsSkillOnly()
// Set up .vscode folder so VS Code scanner detects it
var vscodePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".vscode");
- // Patterns
- var workspacePathPrompt = new CellPatternSearcher().Find("workspace:");
- var configurePrompt = new CellPatternSearcher().Find("configure");
- var skillOption = new CellPatternSearcher().Find("skill");
- var configComplete = new CellPatternSearcher().Find("complete");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create .vscode folder so the scanner detects VS Code environment
- sequenceBuilder
- .CreateVsCodeFolder(vscodePath);
+ Directory.CreateDirectory(vscodePath);
// Run aspire agent init and accept defaults (skill is pre-selected)
- sequenceBuilder
- .Type("aspire agent init")
- .Enter()
- .WaitUntil(s => workspacePathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Wait(500)
- .Enter() // Accept default workspace path
- .WaitUntil(s => configurePrompt.Search(s).Count > 0 && skillOption.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Accept defaults (skill pre-selected)
- .WaitUntil(s => configComplete.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire agent init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitAsync(500);
+ await auto.EnterAsync(); // Accept default workspace path
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("configure") && s.ContainsText("skill"),
+ timeout: TimeSpan.FromSeconds(60), description: "configure prompt with skill option");
+ await auto.EnterAsync(); // Accept defaults (skill pre-selected)
+ await auto.WaitUntilTextAsync("complete", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify skill file was created
var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire", "SKILL.md");
- sequenceBuilder
- .VerifyFileContains(skillFilePath, "aspire start");
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
+ var fileContent = File.ReadAllText(skillFilePath);
+ Assert.Contains("aspire start", fileContent);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs
index b94e710f5b2..43fbdfb4c34 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs
@@ -25,48 +25,32 @@ public async Task Banner_DisplayedOnFirstRun()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern to detect the ASPIRE banner text (the welcome message)
- // The banner displays "Welcome to the" followed by ASCII art "ASPIRE"
- var bannerPattern = new CellPatternSearcher()
- .Find("Welcome to the");
-
- // Pattern to detect the telemetry notice (shown on first run)
- var telemetryNoticePattern = new CellPatternSearcher()
- .Find("Telemetry");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Delete the first-time use sentinel file to simulate first run
// The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel
// Using 'aspire cache clear' because it's not an informational
// command and so will show the banner.
- sequenceBuilder
- .ClearFirstRunSentinel(counter)
- .VerifySentinelDeleted(counter)
- .ClearScreen(counter)
- .Type("aspire cache clear")
- .Enter()
- .WaitUntil(s =>
- {
- // Verify the banner appears
- var hasBanner = bannerPattern.Search(s).Count > 0;
- var hasTelemetryNotice = telemetryNoticePattern.Search(s).Count > 0;
-
- // Both should appear on first run
- return hasBanner && hasTelemetryNotice;
- }, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("rm -f ~/.aspire/cli/cli.firstUseSentinel");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("test ! -f ~/.aspire/cli/cli.firstUseSentinel");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire cache clear");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("Welcome to the") && s.ContainsText("Telemetry"),
+ timeout: TimeSpan.FromSeconds(30), description: "waiting for banner and telemetry notice on first run");
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -82,43 +66,23 @@ public async Task Banner_DisplayedWithExplicitFlag()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern to detect the ASPIRE banner welcome text
- // The banner displays "Welcome to the" followed by ASCII art "ASPIRE"
- var bannerPattern = new CellPatternSearcher()
- .Find("Welcome to the");
-
- // Pattern to detect version info in the banner
- // The format is "CLI — version X.Y.Z"
- var versionPattern = new CellPatternSearcher()
- .Find("CLI");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Clear screen to have a clean slate for pattern matching
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire --banner")
- .Enter()
- .WaitUntil(s =>
- {
- // Verify the banner appears with version info
- var hasBanner = bannerPattern.Search(s).Count > 0;
- var hasVersion = versionPattern.Search(s).Count > 0;
-
- return hasBanner && hasVersion;
- }, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire --banner");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("Welcome to the") && s.ContainsText("CLI"),
+ timeout: TimeSpan.FromSeconds(30), description: "waiting for banner with version info");
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -135,55 +99,41 @@ public async Task Banner_NotDisplayedWithNoLogoFlag()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern to detect the ASPIRE banner - should NOT appear
- // The banner displays "Welcome to the" followed by ASCII art "ASPIRE"
- var bannerPattern = new CellPatternSearcher()
- .Find("Welcome to the");
-
- // Pattern to detect the help text (confirms command completed)
- var helpPattern = new CellPatternSearcher()
- .Find("Commands:");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Delete the first-time use sentinel file to simulate first run,
// but use --nologo to suppress the banner
- sequenceBuilder
- .ClearFirstRunSentinel(counter)
- .ClearScreen(counter)
- .Type("aspire --nologo --help")
- .Enter()
- .WaitUntil(s =>
+ await auto.TypeAsync("rm -f ~/.aspire/cli/cli.firstUseSentinel");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire --nologo --help");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ // Wait for help output to confirm command completed
+ if (!s.ContainsText("Commands:"))
+ {
+ return false;
+ }
+
+ // Verify the banner does NOT appear
+ if (s.ContainsText("Welcome to the"))
{
- // Wait for help output to confirm command completed
- var hasHelp = helpPattern.Search(s).Count > 0;
- if (!hasHelp)
- {
- return false;
- }
-
- // Verify the banner does NOT appear
- var hasBanner = bannerPattern.Search(s).Count > 0;
- if (hasBanner)
- {
- throw new InvalidOperationException(
- "Unexpected banner displayed when --nologo flag was used!");
- }
-
- return true;
- }, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ throw new InvalidOperationException(
+ "Unexpected banner displayed when --nologo flag was used!");
+ }
+
+ return true;
+ }, timeout: TimeSpan.FromSeconds(30), description: "waiting for help output without banner");
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs
index 39b09a14146..f098611595c 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs
@@ -27,44 +27,38 @@ public async Task CreateAndRunAspireStarterProjectWithBundle()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Verify the dashboard is actually reachable, not just that the URL was printed.
- // When the dashboard path bug was present, the URL appeared on screen but curling
- // it returned connection refused because the dashboard process failed to start.
- var waitForDashboardCurlSuccess = new CellPatternSearcher()
- .Find("dashboard-http-200");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
+
+ await auto.AspireNewAsync("BundleStarterApp", counter);
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Start AppHost in detached mode and capture JSON output
+ await auto.TypeAsync("aspire start --format json | tee /tmp/aspire-detach.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Verify the dashboard is reachable by extracting the URL from the detach output
+ // and curling it. Extract just the base URL (https://localhost:PORT) using sed, which is
+ // portable across macOS (BSD) and Linux (GNU) unlike grep -oP.
+ await auto.TypeAsync("DASHBOARD_URL=$(sed -n 's/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https:\\/\\/localhost:[0-9]*\\).*/\\1/p' /tmp/aspire-detach.json | head -1)");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.AspireNew("BundleStarterApp", counter)
- // Start AppHost in detached mode and capture JSON output
- .Type("aspire start --format json | tee /tmp/aspire-detach.json")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3))
- // Verify the dashboard is reachable by extracting the URL from the detach output
- // and curling it. Extract just the base URL (https://localhost:PORT) using sed, which is
- // portable across macOS (BSD) and Linux (GNU) unlike grep -oP.
- .Type("DASHBOARD_URL=$(sed -n 's/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https:\\/\\/localhost:[0-9]*\\).*/\\1/p' /tmp/aspire-detach.json | head -1)")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" || echo 'dashboard-http-failed'")
- .Enter()
- .WaitUntil(s => waitForDashboardCurlSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(15))
- .WaitForSuccessPrompt(counter)
- // Clean up: use aspire stop to gracefully shut down the detached AppHost.
- .Type("aspire stop")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" || echo 'dashboard-http-failed'");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("dashboard-http-200", timeout: TimeSpan.FromSeconds(15));
+ await auto.WaitForSuccessPromptAsync(counter);
- var sequence = sequenceBuilder.Build();
+ // Clean up: use aspire stop to gracefully shut down the detached AppHost.
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs
index 08153aab4d3..266530766c3 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs
@@ -26,32 +26,18 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // aspire update prompts
- var waitingForPerformUpdates = new CellPatternSearcher()
- .Find("Perform updates?");
-
- var waitingForNuGetConfigDirectory = new CellPatternSearcher()
- .Find("Which directory for NuGet.config file?");
-
- var waitingForApplyNuGetConfig = new CellPatternSearcher()
- .Find("Apply these changes to NuGet.config?");
-
- var waitingForUpdateSuccessful = new CellPatternSearcher()
- .Find("Update successful!");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Disable update notifications to prevent the CLI self-update prompt
// from appearing after "Update successful!" and blocking the test.
- sequenceBuilder
- .Type("aspire config set features.updateNotificationsEnabled false -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set features.updateNotificationsEnabled false -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Set up an old-format AppHost project with CPM that has a PackageVersion
// for Aspire.Hosting.AppHost. This simulates a pre-migration project where
@@ -62,71 +48,72 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP
var directoryPackagesPropsPath = Path.Combine(projectDir, "Directory.Packages.props");
var containerAppHostCsprojPath = CliE2ETestHelpers.ToContainerPath(appHostCsprojPath, workspace);
- sequenceBuilder
- .ExecuteCallback(() =>
+ Directory.CreateDirectory(appHostDir);
+
+ File.WriteAllText(appHostCsprojPath, """
+
+
+
+ Exe
+ net9.0
+ true
+
+
+
+
+
+ """);
+
+ File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
+ var builder = DistributedApplication.CreateBuilder(args);
+ builder.Build().Run();
+ """);
+
+ File.WriteAllText(directoryPackagesPropsPath, """
+
+
+ true
+
+
+
+
+
+ """);
+
+ // Use --channel stable to skip the channel selection prompt that appears
+ // in CI when PR hive directories are present.
+ await auto.TypeAsync($"aspire update --project \"{containerAppHostCsprojPath}\" --channel stable");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // confirm "Perform updates?" (default: Yes)
+ // The updater may prompt for a NuGet.config location and ask to apply changes
+ // when the project doesn't have an existing NuGet.config. Accept defaults for both.
+ await auto.WaitUntilTextAsync("Which directory for NuGet.config file?", timeout: TimeSpan.FromSeconds(30));
+ await auto.EnterAsync(); // accept default directory
+ await auto.WaitUntilTextAsync("Apply these changes to NuGet.config?", timeout: TimeSpan.FromSeconds(30));
+ await auto.EnterAsync(); // confirm "Apply these changes to NuGet.config?" (default: Yes)
+ await auto.WaitUntilTextAsync("Update successful!", timeout: TimeSpan.FromSeconds(60));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Verify the PackageVersion for Aspire.Hosting.AppHost was removed
+ {
+ var content = File.ReadAllText(directoryPackagesPropsPath);
+ if (content.Contains("Aspire.Hosting.AppHost"))
{
- Directory.CreateDirectory(appHostDir);
-
- File.WriteAllText(appHostCsprojPath, """
-
-
-
- Exe
- net9.0
- true
-
-
-
-
-
- """);
-
- File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
- var builder = DistributedApplication.CreateBuilder(args);
- builder.Build().Run();
- """);
-
- File.WriteAllText(directoryPackagesPropsPath, """
-
-
- true
-
-
-
-
-
- """);
- })
- // Use --channel stable to skip the channel selection prompt that appears
- // in CI when PR hive directories are present.
- .Type($"aspire update --project \"{containerAppHostCsprojPath}\" --channel stable")
- .Enter()
- .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // confirm "Perform updates?" (default: Yes)
- // The updater may prompt for a NuGet.config location and ask to apply changes
- // when the project doesn't have an existing NuGet.config. Accept defaults for both.
- .WaitUntil(s => waitingForNuGetConfigDirectory.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Enter() // accept default directory
- .WaitUntil(s => waitingForApplyNuGetConfig.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Enter() // confirm "Apply these changes to NuGet.config?" (default: Yes)
- .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- // Verify the PackageVersion for Aspire.Hosting.AppHost was removed
- .VerifyFileDoesNotContain(directoryPackagesPropsPath, "Aspire.Hosting.AppHost")
- // Verify dotnet restore succeeds (would fail with NU1009 without the fix)
- .Type($"dotnet restore \"{containerAppHostCsprojPath}\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120))
- // Clean up: re-enable update notifications
- .Type("aspire config delete features.updateNotificationsEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ throw new InvalidOperationException($"File {directoryPackagesPropsPath} unexpectedly contains: Aspire.Hosting.AppHost");
+ }
+ }
+
+ // Verify dotnet restore succeeds (would fail with NU1009 without the fix)
+ await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
+ // Clean up: re-enable update notifications
+ await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -143,11 +130,11 @@ public async Task AspireAddPackageVersionToDirectoryPackagesProps()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Set up an AppHost project with CPM, but no installed packages
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmTest");
@@ -156,49 +143,50 @@ public async Task AspireAddPackageVersionToDirectoryPackagesProps()
var directoryPackagesPropsPath = Path.Combine(projectDir, "Directory.Packages.props");
var containerAppHostCsprojPath = CliE2ETestHelpers.ToContainerPath(appHostCsprojPath, workspace);
- sequenceBuilder
- .ExecuteCallback(() =>
+ Directory.CreateDirectory(appHostDir);
+
+ File.WriteAllText(appHostCsprojPath, """
+
+
+ Exe
+ net9.0
+ true
+
+
+ """);
+
+ File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
+ var builder = DistributedApplication.CreateBuilder(args);
+ builder.Build().Run();
+ """);
+
+ File.WriteAllText(directoryPackagesPropsPath, """
+
+
+ true
+
+
+ """);
+
+ await auto.TypeAsync($"aspire add Aspire.Hosting.Redis --version 13.1.2");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Verify the PackageVersion for Aspire.Hosting.AppHost was removed
+ {
+ var content = File.ReadAllText(appHostCsprojPath);
+ if (content.Contains("Version=\"13.1.2\""))
{
- Directory.CreateDirectory(appHostDir);
-
- File.WriteAllText(appHostCsprojPath, """
-
-
- Exe
- net9.0
- true
-
-
- """);
-
- File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
- var builder = DistributedApplication.CreateBuilder(args);
- builder.Build().Run();
- """);
-
- File.WriteAllText(directoryPackagesPropsPath, """
-
-
- true
-
-
- """);
- })
- .Type($"aspire add Aspire.Hosting.Redis --version 13.1.2")
- .Enter()
- .WaitForSuccessPrompt(counter)
- // Verify the PackageVersion for Aspire.Hosting.AppHost was removed
- .VerifyFileDoesNotContain(appHostCsprojPath, "Version=\"13.1.2\"")
- // Verify dotnet restore succeeds (would fail with NU1009 if AppHost.csproj contained a version)
- .Type($"dotnet restore \"{containerAppHostCsprojPath}\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120))
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ throw new InvalidOperationException($"File {appHostCsprojPath} unexpectedly contains: Version=\"13.1.2\"");
+ }
+ }
+
+ // Verify dotnet restore succeeds (would fail with NU1009 if AppHost.csproj contained a version)
+ await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs
index 9c4e0da1972..6f35627dcc5 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs
@@ -25,48 +25,35 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern for successful trust output
- var trustSuccessPattern = new CellPatternSearcher()
- .Find("trusted successfully");
-
- // Pattern for doctor showing trusted after fix
- var trustedPattern = new CellPatternSearcher()
- .Find("certificate is trusted");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Generate dev certs WITHOUT trust (creates untrusted cert)
- sequenceBuilder
- .Type("dotnet dev-certs https 2>/dev/null || true")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("dotnet dev-certs https 2>/dev/null || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Configure SSL_CERT_DIR so trust detection works properly on Linux
- sequenceBuilder.ConfigureSslCertDir(counter);
+ await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Run aspire certs trust — should trust the existing cert
- sequenceBuilder
- .Type("aspire certs trust")
- .Enter()
- .WaitUntil(s => trustSuccessPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire certs trust");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("trusted successfully", timeout: TimeSpan.FromSeconds(60));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify doctor now shows the certificate as trusted
- sequenceBuilder
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s => trustedPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire doctor");
+ 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;
}
@@ -82,47 +69,34 @@ public async Task CertificatesClean_RemovesCertificates()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern for successful clean
- var cleanedPattern = new CellPatternSearcher()
- .Find("cleaned successfully");
-
- // Pattern to verify doctor shows no cert after clean
- var noCertPattern = new CellPatternSearcher()
- .Find("No HTTPS development certificate");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Generate dev certs first
- sequenceBuilder
- .Type("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.ConfigureSslCertDir(counter);
+ await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Run aspire certs clean
- sequenceBuilder
- .Type("aspire certs clean")
- .Enter()
- .WaitUntil(s => cleanedPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire certs clean");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("cleaned successfully", timeout: TimeSpan.FromSeconds(60));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify doctor now shows no certificate
- sequenceBuilder
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s => noCertPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire doctor");
+ 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;
}
@@ -138,42 +112,30 @@ public async Task CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern for successful trust
- var trustSuccessPattern = new CellPatternSearcher()
- .Find("trusted successfully");
-
- // Pattern for doctor showing trusted
- var trustedPattern = new CellPatternSearcher()
- .Find("certificate is trusted");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Configure SSL_CERT_DIR so trust detection works properly
- sequenceBuilder.ConfigureSslCertDir(counter);
+ await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Run aspire certs trust with NO pre-existing cert — should create and trust
- sequenceBuilder
- .Type("aspire certs trust")
- .Enter()
- .WaitUntil(s => trustSuccessPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire certs trust");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("trusted successfully", timeout: TimeSpan.FromSeconds(60));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify doctor now shows the certificate as trusted
- sequenceBuilder
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s => trustedPattern.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire doctor");
+ 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;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs
index 966b99e1f5a..fcdd01c3b72 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -35,20 +36,14 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitForCtrlCMessage = new CellPatternSearcher()
- .Find("Press CTRL+C to stop the apphost and exit.");
-
- var waitForUpdatedSettingsMessage = new CellPatternSearcher()
- .Find("Updated settings file at");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// 1. Create a starter project
- sequenceBuilder.AspireNew("HealTest", counter, useRedisCache: false);
+ await auto.AspireNewAsync("HealTest", counter, useRedisCache: false);
// 2. Overwrite aspire.config.json with an invalid apphost path and a JSON comment
var configFilePath = Path.Combine(
@@ -56,67 +51,55 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun()
"HealTest",
"aspire.config.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- var malformedConfig = """
- {
- // This comment should be handled gracefully
- "appHost": {
- "path": "nonexistent/path/to/AppHost.csproj" // this path doesn't exist
- },
- "channel": "stable"
- }
- """;
- File.WriteAllText(configFilePath, malformedConfig);
- });
+ var malformedConfig = """
+ {
+ // This comment should be handled gracefully
+ "appHost": {
+ "path": "nonexistent/path/to/AppHost.csproj" // this path doesn't exist
+ },
+ "channel": "stable"
+ }
+ """;
+ File.WriteAllText(configFilePath, malformedConfig);
// 3. Change into the project directory and run aspire run
// The CLI should detect the invalid path, find the real apphost,
// update the config, and start the app successfully
- sequenceBuilder
- .Type("cd HealTest")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire run")
- .Enter()
- .WaitUntil(s => waitForCtrlCMessage.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .Ctrl().Key(Hex1b.Input.Hex1bKey.C)
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd HealTest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Press CTRL+C to stop the apphost and exit.", timeout: TimeSpan.FromMinutes(3));
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await auto.WaitForSuccessPromptAsync(counter);
// 4. Verify the config file was healed (host-side file check)
- sequenceBuilder.ExecuteCallback(() =>
+ if (!File.Exists(configFilePath))
{
- if (!File.Exists(configFilePath))
- {
- throw new InvalidOperationException(
- $"Config file does not exist after healing: {configFilePath}");
- }
+ throw new InvalidOperationException(
+ $"Config file does not exist after healing: {configFilePath}");
+ }
- var content = File.ReadAllText(configFilePath);
+ var content = File.ReadAllText(configFilePath);
- // The healed config should contain a valid apphost path
- // (pointing to the actual AppHost project)
- if (!content.Contains("HealTest.AppHost"))
- {
- throw new InvalidOperationException(
- $"Config file was not healed with correct AppHost path. Content:\n{content}");
- }
-
- // The invalid path should no longer be present
- if (content.Contains("nonexistent/path"))
- {
- throw new InvalidOperationException(
- $"Config file still contains invalid path after healing. Content:\n{content}");
- }
- });
-
- sequenceBuilder
- .Type("exit")
- .Enter();
+ // The healed config should contain a valid apphost path
+ // (pointing to the actual AppHost project)
+ if (!content.Contains("HealTest.AppHost"))
+ {
+ throw new InvalidOperationException(
+ $"Config file was not healed with correct AppHost path. Content:\n{content}");
+ }
- var sequence = sequenceBuilder.Build();
+ // The invalid path should no longer be present
+ if (content.Contains("nonexistent/path"))
+ {
+ throw new InvalidOperationException(
+ $"Config file still contains invalid path after healing. Content:\n{content}");
+ }
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs
index 8a14c8cef31..ac43007a78b 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs
@@ -17,7 +17,7 @@ namespace Aspire.Cli.EndToEnd.Tests;
///
///
/// Each test bind-mounts a host-side directory as /root/.aspire/ in the container,
-/// enabling direct host-side file creation and verification via ExecuteCallback.
+/// enabling direct host-side file creation and verification.
///
public sealed class ConfigMigrationTests(ITestOutputHelper output)
{
@@ -57,7 +57,7 @@ public sealed class ConfigMigrationTests(ITestOutputHelper output)
///
/// Throws if the file at does not contain all
- /// . Used inside ExecuteCallback.
+ /// .
///
private static void AssertFileContains(string filePath, params string[] expectedStrings)
{
@@ -79,7 +79,7 @@ private static void AssertFileContains(string filePath, params string[] expected
///
/// Throws if the file at contains any of the
- /// . Used inside ExecuteCallback.
+ /// .
///
private static void AssertFileDoesNotContain(string filePath, params string[] unexpectedStrings)
{
@@ -116,68 +116,52 @@ public async Task GlobalSettings_MigratedFromLegacyFormat()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Pre-populate legacy globalsettings.json on the host (visible in container via bind mount).
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- File.WriteAllText(legacyPath,
- """{"channel":"staging","features":{"polyglotSupportEnabled":true},"sdkVersion":"9.1.0"}""");
+ File.WriteAllText(legacyPath,
+ """{"channel":"staging","features":{"polyglotSupportEnabled":true},"sdkVersion":"9.1.0"}""");
- // Ensure no aspire.config.json exists yet.
- if (File.Exists(newConfigPath))
- {
- File.Delete(newConfigPath);
- }
- });
+ // Ensure no aspire.config.json exists yet.
+ if (File.Exists(newConfigPath))
+ {
+ File.Delete(newConfigPath);
+ }
// Run any CLI command to trigger global migration in Program.cs.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify aspire.config.json was created with migrated values (host-side).
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
- });
+ AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Verify the legacy file was preserved (intentional for backward compat).
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(legacyPath, "channel");
- });
+ AssertFileContains(legacyPath, "channel");
// Verify migrated values are accessible via aspire config get.
- var channelPattern = new CellPatternSearcher().Find("staging");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.polyglotSupportEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -197,47 +181,37 @@ public async Task GlobalMigration_SkipsWhenNewConfigExists()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Pre-populate BOTH files on the host: aspire.config.json with "preview",
// globalsettings.json with "staging".
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- File.WriteAllText(newConfigPath,
- """{"channel":"preview"}""");
- File.WriteAllText(legacyPath,
- """{"channel":"staging","features":{"polyglotSupportEnabled":true}}""");
- });
+ File.WriteAllText(newConfigPath,
+ """{"channel":"preview"}""");
+ File.WriteAllText(legacyPath,
+ """{"channel":"staging","features":{"polyglotSupportEnabled":true}}""");
// Run CLI. Migration should be skipped because aspire.config.json already exists.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify aspire.config.json still has "preview" (NOT overwritten with "staging").
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath, "preview");
- AssertFileDoesNotContain(newConfigPath, "staging");
- });
+ AssertFileContains(newConfigPath, "preview");
+ AssertFileDoesNotContain(newConfigPath, "staging");
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -257,57 +231,46 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Write malformed JSON to the legacy file.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- File.WriteAllText(
- Path.Combine(aspireHomeDir, "globalsettings.json"),
- "this is not valid json {{{");
+ File.WriteAllText(
+ Path.Combine(aspireHomeDir, "globalsettings.json"),
+ "this is not valid json {{{");
- if (File.Exists(newConfigPath))
- {
- File.Delete(newConfigPath);
- }
- });
+ if (File.Exists(newConfigPath))
+ {
+ File.Delete(newConfigPath);
+ }
// Run CLI. Should not crash despite malformed legacy file.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify CLI still works by setting and reading a value.
- sequenceBuilder
- .Type("aspire config set channel stable -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- var stablePattern = new CellPatternSearcher().Find("stable");
+ await auto.TypeAsync("aspire config set channel stable -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => stablePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("stable", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -327,69 +290,56 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Write legacy JSON with comments and trailing commas.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- File.WriteAllText(legacyPath,
- """
- {
- // User-added comment
- "channel": "staging",
- "features": {
- "polyglotSupportEnabled": true,
- }
- }
- """);
-
- if (File.Exists(newConfigPath))
+ File.WriteAllText(legacyPath,
+ """
{
- File.Delete(newConfigPath);
+ // User-added comment
+ "channel": "staging",
+ "features": {
+ "polyglotSupportEnabled": true,
+ }
}
- });
+ """);
+
+ if (File.Exists(newConfigPath))
+ {
+ File.Delete(newConfigPath);
+ }
// Run CLI to trigger migration.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify migration succeeded despite comments/trailing commas (host-side).
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
- });
+ AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Verify value accessible via config get.
- var channelPattern = new CellPatternSearcher().Find("staging");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.polyglotSupportEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -410,89 +360,70 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Ensure clean state.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
- sequenceBuilder.ExecuteCallback(() =>
+ foreach (var f in new[] { newConfigPath, legacyPath })
{
- foreach (var f in new[] { newConfigPath, legacyPath })
+ if (File.Exists(f))
{
- if (File.Exists(f))
- {
- File.Delete(f);
- }
+ File.Delete(f);
}
- });
+ }
// Set nested config values.
- sequenceBuilder
- .Type("aspire config set channel preview -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config set features.polyglotSupportEnabled true -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config set features.stagingChannelEnabled false -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel preview -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config set features.polyglotSupportEnabled true -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config set features.stagingChannelEnabled false -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the file has nested JSON structure (host-side).
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath, "features", "polyglotSupportEnabled", "preview");
- });
+ AssertFileContains(newConfigPath, "features", "polyglotSupportEnabled", "preview");
// Verify values are readable via aspire config get.
- var channelPattern = new CellPatternSearcher().Find("preview");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
-
- var truePattern = new CellPatternSearcher().Find("true");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get features.polyglotSupportEnabled")
- .Enter()
- .WaitUntil(s => truePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("preview", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get features.polyglotSupportEnabled");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("true", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify globalsettings.json was NOT created.
- sequenceBuilder.ExecuteCallback(() =>
+ if (File.Exists(legacyPath))
{
- if (File.Exists(legacyPath))
- {
- throw new InvalidOperationException(
- "globalsettings.json should not be created by the new CLI");
- }
- });
+ throw new InvalidOperationException(
+ "globalsettings.json should not be created by the new CLI");
+ }
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.polyglotSupportEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.stagingChannelEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -513,83 +444,70 @@ public async Task GlobalMigration_PreservesAllValueTypes()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a comprehensive legacy globalsettings.json with all value types.
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- File.WriteAllText(legacyPath,
- """
- {
- "channel": "preview",
- "sdkVersion": "9.1.0",
- "features": {
- "polyglotSupportEnabled": true,
- "stagingChannelEnabled": false
- },
- "packages": {
- "Aspire.Hosting.Redis": "9.1.0"
- }
- }
- """);
-
- if (File.Exists(newConfigPath))
+ File.WriteAllText(legacyPath,
+ """
{
- File.Delete(newConfigPath);
+ "channel": "preview",
+ "sdkVersion": "9.1.0",
+ "features": {
+ "polyglotSupportEnabled": true,
+ "stagingChannelEnabled": false
+ },
+ "packages": {
+ "Aspire.Hosting.Redis": "9.1.0"
+ }
}
- });
+ """);
+
+ if (File.Exists(newConfigPath))
+ {
+ File.Delete(newConfigPath);
+ }
// Trigger migration.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify all value types were migrated (host-side).
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath,
- "preview",
- "polyglotSupportEnabled",
- "stagingChannelEnabled",
- "Aspire.Hosting.Redis");
- });
+ AssertFileContains(newConfigPath,
+ "preview",
+ "polyglotSupportEnabled",
+ "stagingChannelEnabled",
+ "Aspire.Hosting.Redis");
// Verify individual value via config get.
- var channelPattern = new CellPatternSearcher().Find("preview");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("preview", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.polyglotSupportEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.stagingChannelEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete packages -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete packages -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
@@ -611,86 +529,71 @@ public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
// Step 1: Install a released CLI that uses the legacy config format.
- sequenceBuilder
- .InstallAspireCliVersion(LegacyCliVersion, counter)
- .SourceAspireCliEnvironment(counter);
+ await auto.InstallAspireCliVersionAsync(LegacyCliVersion, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
// Verify the legacy CLI is installed.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Set global values using the legacy CLI.
// In versions before 13.2, this writes to ~/.aspire/globalsettings.json.
- sequenceBuilder
- .Type("aspire config set channel staging -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config set features.polyglotSupportEnabled true -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel staging -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config set features.polyglotSupportEnabled true -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify values were persisted by the legacy CLI.
- var channelPattern = new CellPatternSearcher().Find("staging");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Snapshot which files exist after using the legacy CLI (for debugging).
- sequenceBuilder
- .ClearScreen(counter)
- .Type("ls -la ~/.aspire/")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("ls -la ~/.aspire/");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Install the new CLI (from this PR), overwriting the legacy CLI.
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 4: Run the new CLI to trigger global migration.
- sequenceBuilder
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Verify aspire.config.json exists with migrated values (host-side).
var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json");
- sequenceBuilder.ExecuteCallback(() =>
- {
- AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
- });
+ AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled");
// Step 6: Verify values are still accessible via the new CLI.
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => channelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Cleanup.
- sequenceBuilder
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete features.polyglotSupportEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs
index cc41b41cea1..4def8ac6116 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs
@@ -26,86 +26,60 @@ public async Task DescribeCommandShowsRunningResources()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop/resources commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- // Pattern for aspire resources output - table header
- var waitForResourcesTableHeader = new CellPatternSearcher()
- .Find("Name");
-
- // Pattern for resources - should show the webfrontend and apiservice
- var waitForWebfrontendResource = new CellPatternSearcher()
- .Find("webfrontend");
-
- var waitForApiserviceResource = new CellPatternSearcher()
- .Find("apiservice");
-
- // Pattern for verifying JSON output was written to file
- var waitForJsonFileWritten = new CellPatternSearcher()
- .Find("webfrontend");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireResourcesTestApp", counter);
+ await auto.AspireNewAsync("AspireResourcesTestApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireResourcesTestApp/AspireResourcesTestApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireResourcesTestApp/AspireResourcesTestApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Wait a bit for resources to stabilize
- sequenceBuilder.Type("sleep 5")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("sleep 5");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Now verify aspire describe shows the running resources (human-readable table)
- sequenceBuilder.Type("aspire describe")
- .Enter()
- .WaitUntil(s => waitForResourcesTableHeader.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitUntil(s => waitForWebfrontendResource.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .WaitUntil(s => waitForApiserviceResource.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Name", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitUntilTextAsync("webfrontend", timeout: TimeSpan.FromSeconds(5));
+ await auto.WaitUntilTextAsync("apiservice", timeout: TimeSpan.FromSeconds(5));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test aspire describe --format json output - pipe to file to avoid terminal buffer issues
- sequenceBuilder.Type("aspire describe --format json > resources.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe --format json > resources.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the JSON file contains expected resources
- sequenceBuilder.Type("cat resources.json | grep webfrontend")
- .Enter()
- .WaitUntil(s => waitForJsonFileWritten.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat resources.json | grep webfrontend");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("webfrontend", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -122,127 +96,109 @@ public async Task DescribeCommandResolvesReplicaNames()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- // Pattern for describe output with friendly name (non-replicated resource)
- var waitForCacheResource = new CellPatternSearcher()
- .Find("cache");
-
// Pattern for describe output showing a specific replica
var waitForApiserviceReplicaName = new CellPatternSearcher()
.FindPattern("apiservice-[a-z0-9]+");
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireReplicaTestApp", counter);
+ await auto.AspireNewAsync("AspireReplicaTestApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireReplicaTestApp/AspireReplicaTestApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireReplicaTestApp/AspireReplicaTestApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Add .WithReplicas(2) to the apiservice resource in the AppHost
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "AspireReplicaTestApp");
- var appHostDir = Path.Combine(projectDir, "AspireReplicaTestApp.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "AspireReplicaTestApp");
+ var appHostDir = Path.Combine(projectDir, "AspireReplicaTestApp.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Add .WithReplicas(2) to the first .WithHttpHealthCheck("/health"); occurrence (apiservice)
- var originalPattern = ".WithHttpHealthCheck(\"/health\");";
- var replacement = ".WithHttpHealthCheck(\"/health\").WithReplicas(2);";
+ // Add .WithReplicas(2) to the first .WithHttpHealthCheck("/health"); occurrence (apiservice)
+ var originalPattern = ".WithHttpHealthCheck(\"/health\");";
+ var replacement = ".WithHttpHealthCheck(\"/health\").WithReplicas(2);";
- // Only replace the first occurrence (apiservice), not the second (webfrontend)
- var index = content.IndexOf(originalPattern);
- if (index >= 0)
- {
- content = content[..index] + replacement + content[(index + originalPattern.Length)..];
- }
+ // Only replace the first occurrence (apiservice), not the second (webfrontend)
+ var index = content.IndexOf(originalPattern);
+ if (index >= 0)
+ {
+ content = content[..index] + replacement + content[(index + originalPattern.Length)..];
+ }
- File.WriteAllText(appHostFilePath, content);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified AppHost.cs to add .WithReplicas(2) to apiservice");
- });
+ output.WriteLine($"Modified AppHost.cs to add .WithReplicas(2) to apiservice");
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Wait for resources to stabilize
- sequenceBuilder.Type("sleep 10")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("sleep 10");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 1: aspire describe with friendly name for a non-replicated resource (cache)
// This should resolve via DisplayName since cache has only one instance
- sequenceBuilder.Type("aspire describe cache --format json > cache-describe.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe cache --format json > cache-describe.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify cache resource was found in the output
- sequenceBuilder.Type("cat cache-describe.json | grep cache")
- .Enter()
- .WaitUntil(s => waitForCacheResource.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat cache-describe.json | grep cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("cache", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 2: Get all resources to find an apiservice replica name
- sequenceBuilder.Type("aspire describe --format json > all-resources.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe --format json > all-resources.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Extract a replica name from the JSON - apiservice replicas have names like apiservice-
- sequenceBuilder.Type("REPLICA_NAME=$(cat all-resources.json | grep -o '\"name\": *\"apiservice-[a-z0-9]*\"' | head -1 | sed 's/.*\"\\(apiservice-[a-z0-9]*\\)\"/\\1/')")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("REPLICA_NAME=$(cat all-resources.json | grep -o '\"name\": *\"apiservice-[a-z0-9]*\"' | head -1 | sed 's/.*\"\\(apiservice-[a-z0-9]*\\)\"/\\1/')");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify we captured a replica name
- sequenceBuilder.Type("echo \"Found replica: $REPLICA_NAME\"")
- .Enter()
- .WaitUntil(s => waitForApiserviceReplicaName.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("echo \"Found replica: $REPLICA_NAME\"");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s => waitForApiserviceReplicaName.Search(s).Count > 0, timeout: TimeSpan.FromSeconds(10), description: "waiting for apiservice replica name");
+ await auto.WaitForSuccessPromptAsync(counter);
// Test 3: aspire describe with the replica name
// This should resolve via exact Name match
- sequenceBuilder.Type("aspire describe $REPLICA_NAME --format json > replica-describe.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe $REPLICA_NAME --format json > replica-describe.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the replica was found and output contains the replica name
- sequenceBuilder.Type("cat replica-describe.json | grep apiservice")
- .Enter()
- .WaitUntil(s => waitForApiserviceReplicaName.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat replica-describe.json | grep apiservice");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s => waitForApiserviceReplicaName.Search(s).Count > 0, timeout: TimeSpan.FromSeconds(10), description: "waiting for apiservice replica in describe output");
+ await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
index 1f61d88a9c0..a6d32122da5 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
@@ -29,49 +29,43 @@ public async Task CreateAndDeployToDockerCompose()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // In CI, aspire add shows a version selection prompt (but aspire new does not when channel is set)
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ // PrepareEnvironment
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (isCI)
{
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
- sequenceBuilder.VerifyAspireCliVersion(commitSha, counter);
+ await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
+ await auto.VerifyAspireCliVersionAsync(commitSha, counter);
}
// Step 1: Create a new Aspire Starter App (no Redis cache)
- sequenceBuilder.AspireNew(ProjectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false);
// Step 2: Navigate into the project directory
- sequenceBuilder.Type($"cd {ProjectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {ProjectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Add Aspire.Hosting.Docker package using aspire add
// Pass the package name directly as an argument to avoid interactive selection
- sequenceBuilder.Type("aspire add Aspire.Hosting.Docker")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set)
if (isCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4: Modify AppHost's main file to add Docker Compose environment
- // We'll use a callback to modify the file during sequence execution
// Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost");
@@ -94,56 +88,52 @@ public async Task CreateAndDeployToDockerCompose()
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ }
// Step 5: Create output directory for deployment artifacts
- sequenceBuilder.Type("mkdir -p deploy-output")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("mkdir -p deploy-output");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 6: Unset ASPIRE_PLAYGROUND before deploy
// ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment,
// which causes Spectre.Console to try to show interactive spinners and prompts concurrently,
// resulting in "Operations with dynamic displays cannot run at the same time" errors.
- sequenceBuilder.Type("unset ASPIRE_PLAYGROUND")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Run aspire deploy to deploy to Docker Compose
// This will build the project, generate Docker Compose files, and start the containers
// Use --non-interactive to avoid any prompts during deployment
- sequenceBuilder.Type("aspire deploy -o deploy-output --non-interactive")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 8: Capture the port from docker ps output for verification
// We need to parse the port from docker ps to make a web request
- sequenceBuilder.Type("docker ps --format '{{.Ports}}' | grep -oE '0\\.0\\.0\\.0:[0-9]+' | head -1 | cut -d: -f2")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("docker ps --format '{{.Ports}}' | grep -oE '0\\.0\\.0\\.0:[0-9]+' | head -1 | cut -d: -f2");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Verify the deployment is running with docker ps
- sequenceBuilder.Type("docker ps")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("docker ps");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Make a web request to verify the application is working
// We'll use curl to make the request
- sequenceBuilder.Type("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(docker ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(docker ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 11: Clean up - stop and remove containers
- sequenceBuilder.Type("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
+ await auto.TypeAsync("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -160,49 +150,43 @@ public async Task CreateAndDeployToDockerComposeInteractive()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // In CI, aspire add shows a version selection prompt (but aspire new does not when channel is set)
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ // PrepareEnvironment
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (isCI)
{
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
- sequenceBuilder.VerifyAspireCliVersion(commitSha, counter);
+ await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
+ await auto.VerifyAspireCliVersionAsync(commitSha, counter);
}
// Step 1: Create a new Aspire Starter App (no Redis cache)
- sequenceBuilder.AspireNew(ProjectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false);
// Step 2: Navigate into the project directory
- sequenceBuilder.Type($"cd {ProjectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {ProjectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Add Aspire.Hosting.Docker package using aspire add
// Pass the package name directly as an argument to avoid interactive selection
- sequenceBuilder.Type("aspire add Aspire.Hosting.Docker")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set)
if (isCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4: Modify AppHost's main file to add Docker Compose environment
- // We'll use a callback to modify the file during sequence execution
// Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost");
@@ -225,57 +209,53 @@ public async Task CreateAndDeployToDockerComposeInteractive()
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ }
// Step 5: Create output directory for deployment artifacts
- sequenceBuilder.Type("mkdir -p deploy-output")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("mkdir -p deploy-output");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 6: Unset ASPIRE_PLAYGROUND before deploy
// ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment,
// which causes Spectre.Console to try to show interactive spinners and prompts concurrently,
// resulting in "Operations with dynamic displays cannot run at the same time" errors.
- sequenceBuilder.Type("unset ASPIRE_PLAYGROUND")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Run aspire deploy to deploy to Docker Compose in INTERACTIVE MODE
// This test specifically validates that the concurrent ShowStatusAsync fix works correctly
// when interactive spinners are enabled (without --non-interactive flag).
// The fix prevents nested ShowStatusAsync calls from causing Spectre.Console errors.
- sequenceBuilder.Type("aspire deploy -o deploy-output")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("aspire deploy -o deploy-output");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 8: Capture the port from docker ps output for verification
// We need to parse the port from docker ps to make a web request
- sequenceBuilder.Type("docker ps --format '{{.Ports}}' | grep -oE '0\\.0\\.0\\.0:[0-9]+' | head -1 | cut -d: -f2")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("docker ps --format '{{.Ports}}' | grep -oE '0\\.0\\.0\\.0:[0-9]+' | head -1 | cut -d: -f2");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Verify the deployment is running with docker ps
- sequenceBuilder.Type("docker ps")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("docker ps");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Make a web request to verify the application is working
// We'll use curl to make the request
- sequenceBuilder.Type("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(docker ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(docker ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 11: Clean up - stop and remove containers
- sequenceBuilder.Type("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
+ await auto.TypeAsync("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs
index 0ef1f67a54a..de658b1dd95 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs
@@ -25,46 +25,30 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern to detect partial trust warning in aspire doctor output
- var partiallyTrustedPattern = new CellPatternSearcher()
- .Find("partially trusted");
-
- // Pattern to detect doctor command completion (shows environment check results)
- var doctorCompletePattern = new CellPatternSearcher()
- .Find("dev-certs");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Generate and trust dev certs inside the container (Docker images don't have them by default)
- sequenceBuilder
- .Type("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Unset SSL_CERT_DIR to trigger partial trust detection on Linux
- sequenceBuilder
- .ClearSslCertDir(counter)
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s =>
- {
- // Wait for doctor to complete and show partial trust warning
- var hasDevCerts = doctorCompletePattern.Search(s).Count > 0;
- var hasPartiallyTrusted = partiallyTrustedPattern.Search(s).Count > 0;
- return hasDevCerts && hasPartiallyTrusted;
- }, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("unset SSL_CERT_DIR");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire doctor");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("dev-certs") && s.ContainsText("partially trusted"),
+ timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with partial trust warning");
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -80,65 +64,44 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern to detect fully trusted certificate
- var trustedPattern = new CellPatternSearcher()
- .Find("certificate is trusted");
-
- // Pattern to detect partial trust (should NOT appear)
- var partiallyTrustedPattern = new CellPatternSearcher()
- .Find("partially trusted");
-
- // Pattern to detect doctor command completion
- var doctorCompletePattern = new CellPatternSearcher()
- .Find("dev-certs");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Generate and trust dev certs inside the container (Docker images don't have them by default)
- sequenceBuilder
- .Type("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Set SSL_CERT_DIR to include dev-certs trust path for full trust
- sequenceBuilder
- .ConfigureSslCertDir(counter)
- .Type("aspire doctor")
- .Enter()
- .WaitUntil(s =>
+ await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire doctor");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ // Wait for doctor to complete
+ if (!s.ContainsText("dev-certs"))
+ {
+ return false;
+ }
+
+ // Fail if we see partial trust when SSL_CERT_DIR is configured
+ if (s.ContainsText("partially trusted"))
{
- // Wait for doctor to complete
- var hasDevCerts = doctorCompletePattern.Search(s).Count > 0;
- if (!hasDevCerts)
- {
- return false;
- }
-
- // Verify we see "trusted" but NOT "partially trusted"
- var hasTrusted = trustedPattern.Search(s).Count > 0;
- var hasPartiallyTrusted = partiallyTrustedPattern.Search(s).Count > 0;
-
- // Fail if we see partial trust when SSL_CERT_DIR is configured
- if (hasPartiallyTrusted)
- {
- throw new InvalidOperationException(
- "Unexpected 'partially trusted' message when SSL_CERT_DIR is configured!");
- }
-
- return hasTrusted;
- }, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ throw new InvalidOperationException(
+ "Unexpected 'partially trusted' message when SSL_CERT_DIR is configured!");
+ }
+
+ return s.ContainsText("certificate is trusted");
+ }, timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with trusted certificate");
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs
index 37bb020a986..6cee7eff6da 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs
@@ -25,28 +25,17 @@ public async Task CreateEmptyAppHostProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // The purpose of this is to keep track of the number of actual shell commands we have
- // executed. This is important because we customize the shell prompt to show either
- // "[n OK] $ " or "[n ERR:exitcode] $ ". This allows us to deterministically wait for a
- // command to complete and for the shell to be ready for more input rather than relying
- // on arbitrary timeouts of mid-command strings.
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
-
- sequenceBuilder.AspireNew("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost);
+ await auto.AspireNewAsync("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost);
// Note: We don't run 'aspire run' for Empty AppHost since there's nothing to run
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
index 58a53d5ba6b..bba8d63422e 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
@@ -21,10 +21,7 @@ internal static async Task PrepareDockerEnvironmentAsync(
TemporaryWorkspace? workspace = null)
{
// Wait for container to be ready (root prompt)
- await auto.WaitUntilAsync(
- s => new CellPatternSearcher().Find("# ").Search(s).Count > 0,
- timeout: TimeSpan.FromSeconds(60),
- description: "Docker container root prompt (# )");
+ await auto.WaitUntilTextAsync("# ", timeout: TimeSpan.FromSeconds(60));
await auto.WaitAsync(500);
@@ -94,4 +91,145 @@ internal static async Task InstallAspireCliInDockerAsync(
throw new ArgumentOutOfRangeException(nameof(installMode));
}
}
+
+ ///
+ /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation.
+ /// Used by tests that run with (bare bash, no Docker).
+ ///
+ internal static async Task PrepareEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ TemporaryWorkspace workspace,
+ SequenceCounter counter)
+ {
+ var waitingForInputPattern = new CellPatternSearcher()
+ .Find("b").RightUntil("$").Right(' ').Right(' ');
+
+ await auto.WaitUntilAsync(
+ s => waitingForInputPattern.Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "initial bash prompt");
+ await auto.WaitAsync(500);
+
+ const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'";
+ await auto.TypeAsync(promptSetup);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Installs the Aspire CLI from PR build artifacts in a non-Docker environment.
+ ///
+ internal static async Task InstallAspireCliFromPullRequestAsync(
+ this Hex1bTerminalAutomator auto,
+ int prNumber,
+ SequenceCounter counter)
+ {
+ var command = $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
+ }
+
+ ///
+ /// Configures the PATH and environment variables for the Aspire CLI in a non-Docker environment.
+ ///
+ internal static async Task SourceAspireCliEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Verifies the installed Aspire CLI version matches the expected commit SHA.
+ ///
+ internal static async Task VerifyAspireCliVersionAsync(
+ this Hex1bTerminalAutomator auto,
+ string commitSha,
+ SequenceCounter counter)
+ {
+ if (commitSha.Length != 40)
+ {
+ throw new ArgumentException($"Commit SHA must be exactly 40 characters, got {commitSha.Length}: '{commitSha}'", nameof(commitSha));
+ }
+
+ var shortCommitSha = commitSha[..8];
+ var expectedVersionSuffix = $"g{shortCommitSha}";
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync(expectedVersionSuffix, timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Installs the Aspire CLI and bundle from PR build artifacts, using the PR head SHA to fetch the install script.
+ ///
+ internal static async Task InstallAspireBundleFromPullRequestAsync(
+ this Hex1bTerminalAutomator auto,
+ int prNumber,
+ SequenceCounter counter)
+ {
+ var command = $"ref=$(gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha') && curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
+ }
+
+ ///
+ /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment.
+ /// Unlike , this includes ~/.aspire in PATH for bundle tools.
+ ///
+ internal static async Task SourceAspireBundleEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Clears the terminal screen by running the clear command and waiting for the prompt.
+ ///
+ internal static async Task ClearScreenAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ await auto.TypeAsync("clear");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Ensures polyglot support is enabled for tests.
+ /// Polyglot support now defaults to enabled, so this is currently a no-op.
+ ///
+ internal static Task EnablePolyglotSupportAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ _ = auto;
+ _ = counter;
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Installs a specific GA version of the Aspire CLI using the install script.
+ ///
+ internal static async Task InstallAspireCliVersionAsync(
+ this Hex1bTerminalAutomator auto,
+ string version,
+ SequenceCounter counter)
+ {
+ var command = $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli.sh | bash -s -- --version \"{version}\"";
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, timeout: TimeSpan.FromSeconds(300));
+ }
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
index 1764158af28..e05570253fb 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
@@ -187,13 +187,10 @@ internal static Hex1bTerminalInputSequenceBuilder VerifyAspireCliVersion(
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))
+ .WaitUntil(s => s.ContainsText(expectedVersionSuffix), TimeSpan.FromSeconds(10))
.WaitForSuccessPrompt(counter);
}
@@ -626,11 +623,8 @@ internal static Hex1bTerminalInputSequenceBuilder PrepareDockerEnvironment(
TemporaryWorkspace? workspace = null)
{
// Docker containers run as root, so bash shows '# ' (not '$ ').
- var waitingForContainerReady = new CellPatternSearcher()
- .Find("# ");
-
builder
- .WaitUntil(s => waitingForContainerReady.Search(s).Count > 0, TimeSpan.FromSeconds(60))
+ .WaitUntil(s => s.ContainsText("# "), TimeSpan.FromSeconds(60))
.Wait(500);
// Set up the same prompt counting mechanism used by all E2E tests.
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs
index 5324ebcceda..2acf4cd94c0 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -25,51 +26,35 @@ public async Task CreateAndRunJsReactProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitForCtrlCMessage = new CellPatternSearcher()
- .Find($"Press CTRL+C to stop the apphost and exit.");
-
- // Regression test for https://github.com/dotnet/aspire/issues/13971
- // If this prompt appears, it means multiple apphosts were incorrectly detected
- // (e.g., AppHost.cs was incorrectly treated as a single-file apphost)
- var unexpectedAppHostSelectionPrompt = new CellPatternSearcher()
- .Find("Select an apphost to use:");
-
- // The purpose of this is to keep track of the number of actual shell commands we have
- // executed. This is important because we customize the shell prompt to show either
- // "[n OK] $ " or "[n ERR:exitcode] $ ". This allows us to deterministically wait for a
- // command to complete and for the shell to be ready for more input rather than relying
- // on arbitrary timeouts of mid-command strings.
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.AspireNewAsync("AspireJsReactApp", counter, template: AspireTemplate.JsReact, useRedisCache: false);
- sequenceBuilder.AspireNew("AspireJsReactApp", counter, template: AspireTemplate.JsReact, useRedisCache: false);
+ // Run the project with aspire run
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
- sequenceBuilder
- .Type("aspire run")
- .Enter()
- .WaitUntil(s =>
+ // Regression test for https://github.com/dotnet/aspire/issues/13971
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("Select an apphost to use:"))
{
- // Fail immediately if we see the apphost selection prompt (means duplicate detection)
- if (unexpectedAppHostSelectionPrompt.Search(s).Count > 0)
- {
- throw new InvalidOperationException(
- "Unexpected apphost selection prompt detected! " +
- "This indicates multiple apphosts were incorrectly detected.");
- }
- return waitForCtrlCMessage.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(2))
- .Ctrl().Key(Hex1b.Input.Hex1bKey.C)
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ throw new InvalidOperationException(
+ "Unexpected apphost selection prompt detected! " +
+ "This indicates multiple apphosts were incorrectly detected.");
+ }
+ return s.ContainsText("Press CTRL+C to stop the apphost and exit.");
+ }, timeout: TimeSpan.FromMinutes(2), description: "Press CTRL+C message (aspire run started)");
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
index 96cbcfdaad1..616ac1986f6 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
@@ -43,106 +43,102 @@ public async Task CreateAndPublishToKubernetes()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // In CI, aspire add shows a version selection prompt (but aspire new does not when channel is set)
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ // Prepare environment
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (isCI)
{
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
- sequenceBuilder.VerifyAspireCliVersion(commitSha, counter);
+ await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
+ await auto.VerifyAspireCliVersionAsync(commitSha, counter);
}
- // =====================================================================
- // Phase 1: Install KinD and Helm tools
- // =====================================================================
-
- // Install kind to ~/.local/bin (no sudo required)
- sequenceBuilder.Type("mkdir -p ~/.local/bin")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder.Type($"curl -sSLo ~/.local/bin/kind \"https://github.com/kubernetes-sigs/kind/releases/download/{KindVersion}/kind-linux-amd64\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type("chmod +x ~/.local/bin/kind")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Install helm to ~/.local/bin
- sequenceBuilder.Type($"curl -sSL https://get.helm.sh/helm-{HelmVersion}-linux-amd64.tar.gz | tar xz -C /tmp")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type("mv /tmp/linux-amd64/helm ~/.local/bin/helm && rm -rf /tmp/linux-amd64")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Add ~/.local/bin to PATH for this session
- sequenceBuilder.Type("export PATH=\"$HOME/.local/bin:$PATH\"")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Verify installations
- sequenceBuilder.Type("kind version && helm version --short")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // =====================================================================
- // Phase 2: Create KinD cluster
- // =====================================================================
-
- // Delete any existing cluster with the same name to ensure a clean state
- sequenceBuilder.Type($"kind delete cluster --name={clusterName} || true")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type($"kind create cluster --name={clusterName} --wait=120s")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
-
- // Verify cluster is ready
- sequenceBuilder.Type($"kubectl cluster-info --context kind-{clusterName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder.Type("kubectl get nodes")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // =====================================================================
- // Phase 3: Create Aspire project and generate Helm chart
- // =====================================================================
-
- // Step 1: Create a new Aspire Starter App
- sequenceBuilder.AspireNew(ProjectName, counter, useRedisCache: false);
-
- // Step 2: Navigate into the project directory
- sequenceBuilder.Type($"cd {ProjectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 3: Add Aspire.Hosting.Kubernetes package using aspire add
- // Pass the package name directly as an argument to avoid interactive selection
- // The version selection prompt always appears for 'aspire add'
- sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes")
- .Enter()
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // select first version
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
-
- // Step 4: Modify AppHost's main file to add Kubernetes environment
- // We'll use a callback to modify the file during sequence execution
- // Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs
- sequenceBuilder.ExecuteCallback(() =>
+ try
{
+ // =====================================================================
+ // Phase 1: Install KinD and Helm tools
+ // =====================================================================
+
+ // Install kind to ~/.local/bin (no sudo required)
+ await auto.TypeAsync("mkdir -p ~/.local/bin");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync($"curl -sSLo ~/.local/bin/kind \"https://github.com/kubernetes-sigs/kind/releases/download/{KindVersion}/kind-linux-amd64\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+
+ await auto.TypeAsync("chmod +x ~/.local/bin/kind");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Install helm to ~/.local/bin
+ await auto.TypeAsync($"curl -sSL https://get.helm.sh/helm-{HelmVersion}-linux-amd64.tar.gz | tar xz -C /tmp");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+
+ await auto.TypeAsync("mv /tmp/linux-amd64/helm ~/.local/bin/helm && rm -rf /tmp/linux-amd64");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Add ~/.local/bin to PATH for this session
+ await auto.TypeAsync("export PATH=\"$HOME/.local/bin:$PATH\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Verify installations
+ await auto.TypeAsync("kind version && helm version --short");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // =====================================================================
+ // Phase 2: Create KinD cluster
+ // =====================================================================
+
+ // Delete any existing cluster with the same name to ensure a clean state
+ await auto.TypeAsync($"kind delete cluster --name={clusterName} || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+
+ await auto.TypeAsync($"kind create cluster --name={clusterName} --wait=120s");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
+
+ // Verify cluster is ready
+ await auto.TypeAsync($"kubectl cluster-info --context kind-{clusterName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("kubectl get nodes");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // =====================================================================
+ // Phase 3: Create Aspire project and generate Helm chart
+ // =====================================================================
+
+ // Step 1: Create a new Aspire Starter App
+ await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false);
+
+ // Step 2: Navigate into the project directory
+ await auto.TypeAsync($"cd {ProjectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 3: Add Aspire.Hosting.Kubernetes package using aspire add
+ // Pass the package name directly as an argument to avoid interactive selection
+ // The version selection prompt always appears for 'aspire add'
+ await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
+
+ // Step 4: Modify AppHost's main file to add Kubernetes environment
+ // Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost");
var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
@@ -164,155 +160,148 @@ public async Task CreateAndPublishToKubernetes()
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
-
- // Step 5: Create output directory for Helm chart artifacts
- sequenceBuilder.Type("mkdir -p helm-output")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 6: Unset ASPIRE_PLAYGROUND before publish
- // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment,
- // which causes Spectre.Console to try to show interactive spinners and prompts concurrently,
- // resulting in "Operations with dynamic displays cannot run at the same time" errors.
- sequenceBuilder.Type("unset ASPIRE_PLAYGROUND")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 7: Run aspire publish to generate Helm charts
- // This will build the project and generate Kubernetes manifests as Helm charts
- // Use --non-interactive to avoid any prompts during publishing
- sequenceBuilder.Type("aspire publish -o helm-output --non-interactive")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
-
- // Step 8: Verify the Helm chart files were generated
- // Check for Chart.yaml (required Helm chart metadata)
- sequenceBuilder.Type("cat helm-output/Chart.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 9: Verify values.yaml exists (Helm values file)
- sequenceBuilder.Type("cat helm-output/values.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 10: Verify templates directory exists with Kubernetes manifests
- sequenceBuilder.Type("ls -la helm-output/templates/")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Step 11: Display the directory structure for debugging
- sequenceBuilder.Type("find helm-output -type f | head -20")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // =====================================================================
- // Phase 4: Build container images using aspire do build
- // =====================================================================
-
- // Build container images for the projects using the Aspire pipeline
- // This uses dotnet publish /t:PublishContainer to build images locally
- sequenceBuilder.Type("aspire do build --non-interactive")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
-
- // List the built Docker images to verify they exist
- // The Starter App builds: apiservice:latest and webfrontend:latest
- sequenceBuilder.Type("docker images | grep -E 'apiservice|webfrontend'")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Load the built images into the KinD cluster
- // KinD runs containers inside Docker, so we need to load images into the cluster's nodes
- sequenceBuilder.Type($"kind load docker-image apiservice:latest --name={clusterName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
-
- sequenceBuilder.Type($"kind load docker-image webfrontend:latest --name={clusterName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
-
- // =====================================================================
- // Phase 5: Deploy the Helm chart to KinD cluster
- // =====================================================================
-
- // Validate the Helm chart before installing
- sequenceBuilder.Type("helm lint helm-output")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Perform a dry-run first to catch any issues
- sequenceBuilder.Type("helm install aspire-app helm-output --dry-run")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- // Show the image and port parameters from values.yaml for debugging
- sequenceBuilder.Type("cat helm-output/values.yaml | grep -E '_image:|port_'")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Install the Helm chart using the real container images built by Aspire
- // The images are already loaded into KinD, so we use the default values.yaml
- // which references apiservice:latest and webfrontend:latest
- // Override ports to ensure unique values per service - the Helm chart may have
- // duplicate port defaults that cause "port already allocated" errors during deployment
- sequenceBuilder.Type("helm install aspire-app helm-output " +
- "--set parameters.apiservice.port_http=8080 " +
- "--set parameters.apiservice.port_https=8443 " +
- "--set parameters.webfrontend.port_http=8081 " +
- "--set parameters.webfrontend.port_https=8444 " +
- "--wait --timeout 3m")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(4));
-
- // Verify the Helm release was created and is deployed
- sequenceBuilder.Type("helm list")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Check that pods are running
- sequenceBuilder.Type("kubectl get pods")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Wait for all pods to be ready (not just created)
- sequenceBuilder.Type("kubectl wait --for=condition=Ready pod --all --timeout=120s")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
-
- // Check all Kubernetes resources were created
- sequenceBuilder.Type("kubectl get all")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Show the deployed configmaps and secrets
- sequenceBuilder.Type("kubectl get configmaps,secrets")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // =====================================================================
- // Phase 6: Cleanup
- // =====================================================================
-
- // Uninstall the Helm release
- sequenceBuilder.Type("helm uninstall aspire-app")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- // Delete the KinD cluster
- sequenceBuilder.Type($"kind delete cluster --name={clusterName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
-
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- try
- {
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ // Step 5: Create output directory for Helm chart artifacts
+ await auto.TypeAsync("mkdir -p helm-output");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 6: Unset ASPIRE_PLAYGROUND before publish
+ // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment,
+ // which causes Spectre.Console to try to show interactive spinners and prompts concurrently,
+ // resulting in "Operations with dynamic displays cannot run at the same time" errors.
+ await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 7: Run aspire publish to generate Helm charts
+ // This will build the project and generate Kubernetes manifests as Helm charts
+ // Use --non-interactive to avoid any prompts during publishing
+ await auto.TypeAsync("aspire publish -o helm-output --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
+
+ // Step 8: Verify the Helm chart files were generated
+ // Check for Chart.yaml (required Helm chart metadata)
+ await auto.TypeAsync("cat helm-output/Chart.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 9: Verify values.yaml exists (Helm values file)
+ await auto.TypeAsync("cat helm-output/values.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 10: Verify templates directory exists with Kubernetes manifests
+ await auto.TypeAsync("ls -la helm-output/templates/");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Step 11: Display the directory structure for debugging
+ await auto.TypeAsync("find helm-output -type f | head -20");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // =====================================================================
+ // Phase 4: Build container images using aspire do build
+ // =====================================================================
+
+ // Build container images for the projects using the Aspire pipeline
+ // This uses dotnet publish /t:PublishContainer to build images locally
+ await auto.TypeAsync("aspire do build --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
+
+ // List the built Docker images to verify they exist
+ // The Starter App builds: apiservice:latest and webfrontend:latest
+ await auto.TypeAsync("docker images | grep -E 'apiservice|webfrontend'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Load the built images into the KinD cluster
+ // KinD runs containers inside Docker, so we need to load images into the cluster's nodes
+ await auto.TypeAsync($"kind load docker-image apiservice:latest --name={clusterName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
+
+ await auto.TypeAsync($"kind load docker-image webfrontend:latest --name={clusterName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
+
+ // =====================================================================
+ // Phase 5: Deploy the Helm chart to KinD cluster
+ // =====================================================================
+
+ // Validate the Helm chart before installing
+ await auto.TypeAsync("helm lint helm-output");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Perform a dry-run first to catch any issues
+ await auto.TypeAsync("helm install aspire-app helm-output --dry-run");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+
+ // Show the image and port parameters from values.yaml for debugging
+ await auto.TypeAsync("cat helm-output/values.yaml | grep -E '_image:|port_'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Install the Helm chart using the real container images built by Aspire
+ // The images are already loaded into KinD, so we use the default values.yaml
+ // which references apiservice:latest and webfrontend:latest
+ // Override ports to ensure unique values per service - the Helm chart may have
+ // duplicate port defaults that cause "port already allocated" errors during deployment
+ await auto.TypeAsync("helm install aspire-app helm-output " +
+ "--set parameters.apiservice.port_http=8080 " +
+ "--set parameters.apiservice.port_https=8443 " +
+ "--set parameters.webfrontend.port_http=8081 " +
+ "--set parameters.webfrontend.port_https=8444 " +
+ "--wait --timeout 3m");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
+
+ // Verify the Helm release was created and is deployed
+ await auto.TypeAsync("helm list");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Check that pods are running
+ await auto.TypeAsync("kubectl get pods");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Wait for all pods to be ready (not just created)
+ await auto.TypeAsync("kubectl wait --for=condition=Ready pod --all --timeout=120s");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
+
+ // Check all Kubernetes resources were created
+ await auto.TypeAsync("kubectl get all");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Show the deployed configmaps and secrets
+ await auto.TypeAsync("kubectl get configmaps,secrets");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // =====================================================================
+ // Phase 6: Cleanup
+ // =====================================================================
+
+ // Uninstall the Helm release
+ await auto.TypeAsync("helm uninstall aspire-app");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Delete the KinD cluster
+ await auto.TypeAsync($"kind delete cluster --name={clusterName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
}
finally
{
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs
index c63cdda97c7..c09c5c091c0 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs
@@ -26,91 +26,68 @@ public async Task LogsCommandShowsResourceLogs()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- // Pattern for verifying log output was written to file
- var waitForApiserviceLogs = new CellPatternSearcher()
- .Find("[apiservice]");
-
- // Pattern for verifying JSON log output was written to file
- var waitForLogsJsonOutput = new CellPatternSearcher()
- .Find("\"resourceName\":");
-
- // Pattern for aspire logs when no AppHosts running
- var waitForNoRunningAppHosts = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireLogsTestApp", counter);
+ await auto.AspireNewAsync("AspireLogsTestApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireLogsTestApp/AspireLogsTestApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireLogsTestApp/AspireLogsTestApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Wait for resources to fully start and produce logs
- sequenceBuilder.Type("sleep 15")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("sleep 15");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Test aspire logs for a specific resource (apiservice) - non-follow mode gets logs and exits
- sequenceBuilder.Type("aspire logs apiservice > logs.txt 2>&1")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire logs apiservice > logs.txt 2>&1");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Debug: show file size and first few lines
- sequenceBuilder.Type("wc -l logs.txt && head -5 logs.txt")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("wc -l logs.txt && head -5 logs.txt");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the log file contains expected output
- sequenceBuilder.Type("cat logs.txt | grep -E '\\[apiservice\\]' | head -3")
- .Enter()
- .WaitUntil(s => waitForApiserviceLogs.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat logs.txt | grep -E '\\[apiservice\\]' | head -3");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("[apiservice]", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test aspire logs --format json for a specific resource
- sequenceBuilder.Type("aspire logs apiservice --format json > logs_json.txt 2>&1")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire logs apiservice --format json > logs_json.txt 2>&1");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the JSON log file contains expected output
- sequenceBuilder.Type("cat logs_json.txt | grep '\"resourceName\"' | head -3")
- .Enter()
- .WaitUntil(s => waitForLogsJsonOutput.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat logs_json.txt | grep '\"resourceName\"' | head -3");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("\"resourceName\":", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs
index a6b921b0ba2..dcb5a45ad41 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs
@@ -27,66 +27,56 @@ public async Task DetachFormatJsonProducesValidJson()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a single project using aspire new
- sequenceBuilder.AspireNew("TestApp", counter);
+ await auto.AspireNewAsync("TestApp", counter);
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Navigate into the project directory
- sequenceBuilder
- .Type("cd TestApp")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd TestApp");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// First: launch the apphost with --detach (interactive, no JSON)
// Just wait for the command to complete (WaitForSuccessPrompt waits for the shell prompt)
- sequenceBuilder
- .Type("aspire start")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Second: launch again with --detach --format json, redirecting stdout to a file.
// This tests that the JSON output is well-formed and not polluted by human-readable messages.
// stderr is left visible in the terminal for debugging (human-readable messages go to stderr
// when --format json is used, which is exactly what this PR validates).
- sequenceBuilder
- .Type("aspire start --format json > output.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start --format json > output.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Validate the JSON output file is well-formed by using python to parse it
- sequenceBuilder
- .Type("python3 -c \"import json; data = json.load(open('output.json')); print('JSON_VALID'); print('appHostPath' in data); print('appHostPid' in data)\"")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("python3 -c \"import json; data = json.load(open('output.json')); print('JSON_VALID'); print('appHostPath' in data); print('appHostPid' in data)\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Also cat the file so we can see it in the recording
- sequenceBuilder
- .Type("cat output.json")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cat output.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Clean up: stop any running instances
- sequenceBuilder
- .Type("aspire stop --all 2>/dev/null || true")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire stop --all 2>/dev/null || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
index d8a3664deee..57beb39ecfc 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
@@ -35,86 +35,65 @@ public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Patterns for prompt detection
- var workspacePrompt = new CellPatternSearcher().Find("workspace:");
- var agentEnvPrompt = new CellPatternSearcher().Find("agent environments");
- var additionalOptionsPrompt = new CellPatternSearcher().Find("additional options");
- var playwrightOption = new CellPatternSearcher().Find("Install Playwright CLI");
- var configComplete = new CellPatternSearcher().Find("configuration complete");
- var skillFileExists = new CellPatternSearcher().Find("SKILL.md");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Verify playwright-cli is not installed.
- sequenceBuilder
- .Type("playwright-cli --version 2>&1 || true")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("playwright-cli --version 2>&1 || true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Create an Aspire project (accept all defaults).
- sequenceBuilder.AspireNew("TestProject", counter);
+ await auto.AspireNewAsync("TestProject", counter);
// Step 3: Navigate into the project and create .claude folder to trigger Claude Code detection.
- sequenceBuilder
- .Type("cd TestProject && mkdir -p .claude")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd TestProject && mkdir -p .claude");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 4: Run aspire agent init.
// First prompt: workspace path
- sequenceBuilder
- .Type("aspire agent init")
- .Enter()
- .WaitUntil(s => workspacePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Wait(500)
- .Enter(); // Accept default workspace path
+ await auto.TypeAsync("aspire agent init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitAsync(500);
+ await auto.EnterAsync(); // Accept default workspace path
// Second prompt: agent environments (select Claude Code)
- sequenceBuilder
- .WaitUntil(s => agentEnvPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Type(" ") // Toggle first option (Claude Code)
- .Enter();
+ await auto.WaitUntilTextAsync("agent environments", timeout: TimeSpan.FromSeconds(60));
+ await auto.TypeAsync(" "); // Toggle first option (Claude Code)
+ await auto.EnterAsync();
// Third prompt: additional options (select Playwright CLI installation)
// Aspire skill file (priority 0) appears first, Playwright CLI (priority 1) second.
- sequenceBuilder
- .WaitUntil(s => additionalOptionsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitUntil(s => playwrightOption.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .Type(" ") // Toggle first option (Aspire skill file)
- .Key(Hex1b.Input.Hex1bKey.DownArrow) // Move to Playwright CLI option
- .Type(" ") // Toggle Playwright CLI option
- .Enter();
+ await auto.WaitUntilTextAsync("additional options", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitUntilTextAsync("Install Playwright CLI", timeout: TimeSpan.FromSeconds(10));
+ await auto.TypeAsync(" "); // Toggle first option (Aspire skill file)
+ await auto.DownAsync(); // Move to Playwright CLI option
+ await auto.TypeAsync(" "); // Toggle Playwright CLI option
+ await auto.EnterAsync();
// Wait for installation to complete (this downloads from npm, can take a while)
- sequenceBuilder
- .WaitUntil(s => configComplete.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Verify playwright-cli is now installed.
- sequenceBuilder
- .Type("playwright-cli --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("playwright-cli --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 6: Verify the skill file was generated.
- sequenceBuilder
- .Type("ls .claude/skills/playwright-cli/SKILL.md")
- .Enter()
- .WaitUntil(s => skillFileExists.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
+ await auto.TypeAsync("ls .claude/skills/playwright-cli/SKILL.md");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
index 67432e19159..1e85cb4a5dc 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
@@ -28,36 +28,20 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
-
- var waitForStartSuccess = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- // Pattern to verify our custom integration was code-generated
- var waitForAddMyServiceInCodegen = new CellPatternSearcher()
- .Find("addMyService");
-
- // Pattern to verify the resource appears in describe output
- var waitForMyServiceInDescribe = new CellPatternSearcher()
- .Find("my-svc");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Create a TypeScript AppHost (so we get the SDK version in aspire.config.json)
- sequenceBuilder
- .Type("aspire init --language typescript --non-interactive")
- .Enter()
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire init --language typescript --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Create the integration project, update aspire.config.json, and modify apphost.ts
- sequenceBuilder.ExecuteCallback(() =>
{
var workDir = workspace.WorkspaceRoot.FullName;
@@ -155,67 +139,53 @@ public static IResourceBuilder AddMyService(
await builder.addMyService("my-svc");
await builder.build().run();
""");
- });
+ }
// Step 3: Start the AppHost (triggers project ref build + codegen)
- // Detect either success or failure
- var waitForStartFailure = new CellPatternSearcher()
- .Find("AppHost failed to build");
-
- sequenceBuilder
- .Type("aspire start --non-interactive 2>&1 | tee /tmp/aspire-start-output.txt")
- .Enter()
- .WaitUntil(s =>
+ await auto.TypeAsync("aspire start --non-interactive 2>&1 | tee /tmp/aspire-start-output.txt");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("AppHost failed to build"))
{
- if (waitForStartFailure.Search(s).Count > 0)
- {
- // Dump child logs before failing
- return true;
- }
- return waitForStartSuccess.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ // Dump child logs before failing
+ return true;
+ }
+ return s.ContainsText("AppHost started successfully.");
+ }, timeout: TimeSpan.FromMinutes(2), description: "waiting for apphost start success or failure");
+ await auto.WaitForSuccessPromptAsync(counter);
// If start failed, dump the child log for debugging before the test fails
- sequenceBuilder
- .Type("CHILD_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1) && if [ -n \"$CHILD_LOG\" ]; then echo '=== CHILD LOG ==='; cat \"$CHILD_LOG\"; echo '=== END CHILD LOG ==='; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("CHILD_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1) && if [ -n \"$CHILD_LOG\" ]; then echo '=== CHILD LOG ==='; cat \"$CHILD_LOG\"; echo '=== END CHILD LOG ==='; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
// Step 4: Verify the custom integration was code-generated
- sequenceBuilder
- .Type("grep addMyService .modules/aspire.ts")
- .Enter()
- .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("grep addMyService .modules/aspire.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("addMyService", timeout: TimeSpan.FromSeconds(5));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Wait for the custom resource to be up
- sequenceBuilder
- .Type("aspire wait my-svc --timeout 60")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(90));
+ await auto.TypeAsync("aspire wait my-svc --timeout 60");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
// Step 6: Verify the resource appears in describe
- sequenceBuilder
- .Type("aspire describe my-svc --format json > /tmp/my-svc-describe.json")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(15))
- .Type("cat /tmp/my-svc-describe.json")
- .Enter()
- .WaitUntil(s => waitForMyServiceInDescribe.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire describe my-svc --format json > /tmp/my-svc-describe.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(15));
+ await auto.TypeAsync("cat /tmp/my-svc-describe.json");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("my-svc", timeout: TimeSpan.FromSeconds(5));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Clean up
- sequenceBuilder
- .Type("aspire stop")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs
index e073cf8c7c5..bf5842ff459 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs
@@ -26,83 +26,60 @@ public async Task PsCommandListsRunningAppHost()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop/ps commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- // Pattern for aspire ps output - should show the AppHost path and PID columns
- var waitForPsOutputWithAppHost = new CellPatternSearcher()
- .Find("AspirePsTestApp.AppHost");
-
- // Pattern for aspire ps JSON output
- var waitForPsJsonOutput = new CellPatternSearcher()
- .Find("\"appHostPath\":");
-
- // Pattern for aspire ps when no AppHosts running
- var waitForNoRunningAppHosts = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspirePsTestApp", counter);
+ await auto.AspireNewAsync("AspirePsTestApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspirePsTestApp/AspirePsTestApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspirePsTestApp/AspirePsTestApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// First, verify aspire ps shows no running AppHosts
- sequenceBuilder.Type("aspire ps")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHosts.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire ps");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Now verify aspire ps shows the running AppHost
- sequenceBuilder.Type("aspire ps")
- .Enter()
- .WaitUntil(s => waitForPsOutputWithAppHost.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire ps");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AspirePsTestApp.AppHost", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Test aspire ps --format json output
- sequenceBuilder.Type("aspire ps --format json")
- .Enter()
- .WaitUntil(s => waitForPsJsonOutput.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire ps --format json");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("\"appHostPath\":", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify aspire ps shows no running AppHosts again after stop
- sequenceBuilder.Type("aspire ps")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHosts.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire ps");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -120,11 +97,11 @@ public async Task PsFormatJsonOutputsOnlyJsonToStdout()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
var outputFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "ps-output.json");
var containerOutputFilePath = CliE2ETestHelpers.ToContainerPath(outputFilePath, workspace);
@@ -134,24 +111,17 @@ public async Task PsFormatJsonOutputsOnlyJsonToStdout()
// JSON output goes to stdout (redirected to the file).
// We only wait for the success prompt since the Spectre status spinner is
// transient and erased before WaitUntil polling can observe it.
- sequenceBuilder.Type($"aspire ps --format json > {containerOutputFilePath}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"aspire ps --format json > {containerOutputFilePath}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the file contains only the expected JSON output (empty array).
- sequenceBuilder.ExecuteCallback(() =>
- {
- var content = File.ReadAllText(outputFilePath).Trim();
- Assert.Equal("[]", content);
- });
+ var content = File.ReadAllText(outputFilePath).Trim();
+ Assert.Equal("[]", content);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
index ba1dd10b71d..729773b008d 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -25,35 +26,24 @@ public async Task CreateAndRunPythonReactProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitForCtrlCMessage = new CellPatternSearcher()
- .Find($"Press CTRL+C to stop the apphost and exit.");
-
- // The purpose of this is to keep track of the number of actual shell commands we have
- // executed. This is important because we customize the shell prompt to show either
- // "[n OK] $ " or "[n ERR:exitcode] $ ". This allows us to deterministically wait for a
- // command to complete and for the shell to be ready for more input rather than relying
- // on arbitrary timeouts of mid-command strings.
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- sequenceBuilder.AspireNew("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false);
+ await auto.AspireNewAsync("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false);
- sequenceBuilder
- .Type("aspire run")
- .Enter()
- .WaitUntil(s => waitForCtrlCMessage.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Ctrl().Key(Hex1b.Input.Hex1bKey.C)
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
+ // Run the project with aspire run
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Press CTRL+C to stop the apphost and exit.", timeout: TimeSpan.FromMinutes(2));
- var sequence = sequenceBuilder.Build();
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await auto.WaitForSuccessPromptAsync(counter);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs
index 8ca27d32252..13d43374a77 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs
@@ -25,80 +25,58 @@ public async Task SecretCrudOnDotNetAppHost()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create an Empty AppHost project interactively
- sequenceBuilder.AspireNew("TestSecrets", counter, template: AspireTemplate.EmptyAppHost);
+ await auto.AspireNewAsync("TestSecrets", counter, template: AspireTemplate.EmptyAppHost);
// cd into the project
- sequenceBuilder
- .Type("cd TestSecrets")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd TestSecrets");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Set secrets
- var waitingForSetSuccess = new CellPatternSearcher()
- .Find("set successfully");
+ await auto.TypeAsync("aspire secret set Azure:Location eastus2");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("set successfully", timeout: TimeSpan.FromSeconds(60));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder
- .Type("aspire secret set Azure:Location eastus2")
- .Enter()
- .WaitUntil(s => waitingForSetSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("aspire secret set Parameters:db-password s3cret")
- .Enter()
- .WaitUntil(s => waitingForSetSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret set Parameters:db-password s3cret");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("set successfully", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Get
- var waitingForGetValue = new CellPatternSearcher()
- .Find("eastus2");
-
- sequenceBuilder
- .Type("aspire secret get Azure:Location")
- .Enter()
- .WaitUntil(s => waitingForGetValue.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret get Azure:Location");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("eastus2", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// List
- var waitingForListOutput = new CellPatternSearcher()
- .Find("db-password");
-
- sequenceBuilder
- .Type("aspire secret list")
- .Enter()
- .WaitUntil(s => waitingForListOutput.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret list");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("db-password", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Delete
- var waitingForDeleteSuccess = new CellPatternSearcher()
- .Find("deleted successfully");
-
- sequenceBuilder
- .Type("aspire secret delete Azure:Location")
- .Enter()
- .WaitUntil(s => waitingForDeleteSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret delete Azure:Location");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("deleted successfully", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify deletion
- sequenceBuilder
- .Type("aspire secret list")
- .Enter()
- .WaitUntil(s => waitingForListOutput.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire secret list");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("db-password", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs
index 52d7cc46810..e1645b0be32 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs
@@ -25,95 +25,60 @@ public async Task SecretCrudOnTypeScriptAppHost()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- // Patterns for project creation
- var waitingForLanguagePrompt = new CellPatternSearcher()
- .Find("> C#");
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- var waitingForTypeScriptSelected = new CellPatternSearcher()
- .Find("> TypeScript (Node.js)");
-
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
-
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
-
- // Enable polyglot support
- sequenceBuilder.EnablePolyglotSupport(counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create TypeScript AppHost via aspire init
- sequenceBuilder
- .Type("aspire init")
- .Enter()
- .WaitUntil(s => waitingForLanguagePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Key(Hex1b.Input.Hex1bKey.DownArrow)
- .WaitUntil(s => waitingForTypeScriptSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .Enter()
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .DeclineAgentInitPrompt(counter);
+ await auto.TypeAsync("aspire init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("> C#", timeout: TimeSpan.FromSeconds(30));
+ await auto.DownAsync();
+ await auto.WaitUntilTextAsync("> TypeScript (Node.js)", timeout: TimeSpan.FromSeconds(5));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.DeclineAgentInitPromptAsync(counter);
// Set secrets using --apphost
- var waitingForSetSuccess = new CellPatternSearcher()
- .Find("set successfully");
-
- sequenceBuilder
- .Type("aspire secret set MyConfig:ApiKey test-key-123 --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForSetSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret set MyConfig:ApiKey test-key-123 --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("set successfully", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder
- .Type("aspire secret set ConnectionStrings:Db Server=localhost --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForSetSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret set ConnectionStrings:Db Server=localhost --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("set successfully", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Get
- var waitingForGetValue = new CellPatternSearcher()
- .Find("test-key-123");
-
- sequenceBuilder
- .Type("aspire secret get MyConfig:ApiKey --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForGetValue.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret get MyConfig:ApiKey --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("test-key-123", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// List
- var waitingForListOutput = new CellPatternSearcher()
- .Find("ConnectionStrings:Db");
-
- sequenceBuilder
- .Type("aspire secret list --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForListOutput.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret list --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("ConnectionStrings:Db", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Delete
- var waitingForDeleteSuccess = new CellPatternSearcher()
- .Find("deleted successfully");
-
- sequenceBuilder
- .Type("aspire secret delete MyConfig:ApiKey --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForDeleteSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire secret delete MyConfig:ApiKey --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("deleted successfully", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify deletion
- sequenceBuilder
- .Type("aspire secret list --apphost apphost.ts")
- .Enter()
- .WaitUntil(s => waitingForListOutput.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire secret list --apphost apphost.ts");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("ConnectionStrings:Db", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
await pendingRun;
}
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs
index 2635cade807..27d16b0d9c6 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -26,55 +27,43 @@ public async Task CreateAndRunAspireStarterProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitForCtrlCMessage = new CellPatternSearcher()
- .Find($"Press CTRL+C to stop the apphost and exit.");
-
- // Regression test for https://github.com/dotnet/aspire/issues/13971
- // If this prompt appears, it means multiple apphosts were incorrectly detected
- // (e.g., AppHost.cs was incorrectly treated as a single-file apphost)
- var unexpectedAppHostSelectionPrompt = new CellPatternSearcher()
- .Find("Select an apphost to use:");
-
- // The purpose of this is to keep track of the number of actual shell commands we have
- // executed. This is important because we customize the shell prompt to show either
- // "[n OK] $ " or "[n ERR:exitcode] $ ". This allows us to deterministically wait for a
- // command to complete and for the shell to be ready for more input rather than relying
- // on arbitrary timeouts of mid-command strings. We pass the counter into places where
- // we need to wait for command completion and use the value of the counter to detect
- // the command sequence output. We cannot hard code this value for each WaitForSuccessPrompt
- // call because depending on whether we are running CI or locally we might want to change
- // the commands we run and hence the sequence numbers. The commands we run can also
- // vary by platform, for example on Windows we can skip sourcing the environment the
- // way we do on Linux/macOS.
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+
+ // Prepare Docker environment (prompt counting, umask, env vars)
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Install the Aspire CLI
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Create a new project using aspire new
+ await auto.AspireNewAsync("AspireStarterApp", counter);
- sequenceBuilder.AspireNew("AspireStarterApp", counter)
- .Type("aspire run")
- .Enter()
- .WaitUntil(s =>
+ // Run the project with aspire run
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
+
+ // Regression test for https://github.com/dotnet/aspire/issues/13971
+ // If the apphost selection prompt appears, it means multiple apphosts were
+ // incorrectly detected (e.g., AppHost.cs was incorrectly treated as a single-file apphost)
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("Select an apphost to use:"))
{
- // Fail immediately if we see the apphost selection prompt (means duplicate detection)
- if (unexpectedAppHostSelectionPrompt.Search(s).Count > 0)
- {
- throw new InvalidOperationException(
- "Unexpected apphost selection prompt detected! " +
- "This indicates multiple apphosts were incorrectly detected.");
- }
- return waitForCtrlCMessage.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(2))
- .Ctrl().Key(Hex1b.Input.Hex1bKey.C)
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
+ throw new InvalidOperationException(
+ "Unexpected apphost selection prompt detected! " +
+ "This indicates multiple apphosts were incorrectly detected.");
+ }
+ return s.ContainsText("Press CTRL+C to stop the apphost and exit.");
+ }, timeout: TimeSpan.FromMinutes(2), description: "Press CTRL+C message (aspire run started)");
- var sequence = sequenceBuilder.Build();
+ // Stop the running apphost with Ctrl+C
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await auto.WaitForSuccessPromptAsync(counter);
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ // Exit the shell
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs
index ff9389821dd..33453092e00 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs
@@ -27,130 +27,100 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Configure staging channel settings via aspire config set
// Enable the staging channel feature flag
- sequenceBuilder
- .Type("aspire config set features.stagingChannelEnabled true -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set features.stagingChannelEnabled true -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Set quality to Prerelease (triggers shared feed mode)
- sequenceBuilder
- .Type("aspire config set overrideStagingQuality Prerelease -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set overrideStagingQuality Prerelease -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Enable pinned version mode
- sequenceBuilder
- .Type("aspire config set stagingPinToCliVersion true -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set stagingPinToCliVersion true -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Set channel to staging
- sequenceBuilder
- .Type("aspire config set channel staging -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel staging -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Verify the settings were persisted in the global config file
- var settingsFilePattern = new CellPatternSearcher()
- .Find("stagingPinToCliVersion");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("cat ~/.aspire/aspire.config.json")
- .Enter()
- .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("cat ~/.aspire/aspire.config.json");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("stagingPinToCliVersion", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Verify aspire config get returns the correct values
- var stagingChannelPattern = new CellPatternSearcher()
- .Find("staging");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 4: Verify the CLI version is available (basic smoke test that the CLI works with these settings)
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Switch channel to stable via config set (simulating what update --self does)
- sequenceBuilder
- .Type("aspire config set channel stable -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel stable -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 6: Verify channel was changed to stable
- var stableChannelPattern = new CellPatternSearcher()
- .Find("stable");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("stable", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Switch back to staging
- sequenceBuilder
- .Type("aspire config set channel staging -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel staging -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: Verify channel is staging again and staging settings are still present
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get channel")
- .Enter()
- .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get channel");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the staging-specific settings survived the channel switch
- var prereleasePattern = new CellPatternSearcher()
- .Find("Prerelease");
-
- sequenceBuilder
- .ClearScreen(counter)
- .Type("aspire config get overrideStagingQuality")
- .Enter()
- .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10))
- .WaitForSuccessPrompt(counter);
+ await auto.ClearScreenAsync(counter);
+ await auto.TypeAsync("aspire config get overrideStagingQuality");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Prerelease", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clean up: remove staging settings to avoid polluting other tests
- sequenceBuilder
- .Type("aspire config delete features.stagingChannelEnabled -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete overrideStagingQuality -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete stagingPinToCliVersion -g")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire config delete channel -g")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete overrideStagingQuality -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete stagingPinToCliVersion -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire config delete channel -g");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
index 7db9cbcdd08..a0d03c7b7dd 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
@@ -26,47 +26,38 @@ public async Task CreateStartAndStopAspireProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Prepare Docker environment (prompt counting, umask, env vars)
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Install the Aspire CLI
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireStarterApp", counter);
+ await auto.AspireNewAsync("AspireStarterApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireStarterApp/AspireStarterApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireStarterApp/AspireStarterApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -83,30 +74,24 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searcher for the informational message (not an error)
- var waitForNoRunningAppHosts = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Prepare Docker environment (prompt counting, umask, env vars)
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Install the Aspire CLI
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Run aspire stop with no running AppHost - should exit with code 0
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHosts.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -123,62 +108,50 @@ public async Task AddPackageWhileAppHostRunningDetached()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for detach/add/stop
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForPackageAddedSuccessfully = new CellPatternSearcher()
- .Find("was added successfully.");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Prepare Docker environment (prompt counting, umask, env vars)
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Install the Aspire CLI
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireAddTestApp", counter);
+ await auto.AspireNewAsync("AspireAddTestApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireAddTestApp/AspireAddTestApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireAddTestApp/AspireAddTestApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in detached mode (locks the project file)
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Add a package while the AppHost is running - this should auto-stop the
// running instance before modifying the project, then succeed.
// --non-interactive skips the version selection prompt.
- sequenceBuilder.Type("aspire add mongodb --non-interactive")
- .Enter()
- .WaitUntil(s => waitForPackageAddedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire add mongodb --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("was added successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clean up: stop if still running (the add command may have stopped it)
// aspire stop may return a non-zero exit code if no instances are found
// (already stopped by aspire add), so wait for known output patterns.
- var waitForStopResult = new CellPatternSearcher()
- .Find("No running AppHost found");
- var waitForStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForStopResult.Search(s).Count > 0 || waitForStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .IncrementSequence(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ s.ContainsText("No running AppHost found") || s.ContainsText("AppHost stopped successfully."),
+ timeout: TimeSpan.FromMinutes(1), description: "AppHost stopped or no running AppHost");
+ await auto.WaitForAnyPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -195,73 +168,55 @@ public async Task AddPackageInteractiveWhileAppHostRunningDetached()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for detach/add/stop
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- var waitForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("Select a version of");
-
- var waitForPackageAddedSuccessfully = new CellPatternSearcher()
- .Find("was added successfully.");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ // Prepare Docker environment (prompt counting, umask, env vars)
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ // Install the Aspire CLI
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("AspireAddInteractiveApp", counter);
+ await auto.AspireNewAsync("AspireAddInteractiveApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd AspireAddInteractiveApp/AspireAddInteractiveApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd AspireAddInteractiveApp/AspireAddInteractiveApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in detached mode (locks the project file)
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Run aspire add interactively (no integration argument) while AppHost is running.
// This exercises the interactive package selection flow and verifies the
// running instance is auto-stopped before modifying the project.
- sequenceBuilder.Type("aspire add")
- .Enter()
- .WaitUntil(s => waitForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .Type("mongodb") // type to filter the list
- .Enter() // select the filtered result
- .WaitUntil(s => waitForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Enter() // Accept the default version
- .WaitUntil(s => waitForPackageAddedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire add");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromMinutes(1));
+ await auto.TypeAsync("mongodb"); // type to filter the list
+ await auto.EnterAsync(); // select the filtered result
+ await auto.WaitUntilTextAsync("Select a version of", timeout: TimeSpan.FromSeconds(30));
+ await auto.EnterAsync(); // Accept the default version
+ await auto.WaitUntilTextAsync("was added successfully.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clean up: stop if still running
// aspire stop may return a non-zero exit code if no instances are found
// (already stopped by aspire add), so wait for known output patterns.
- var waitForStopResult2 = new CellPatternSearcher()
- .Find("No running AppHost found");
- var waitForStoppedSuccessfully2 = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- sequenceBuilder.Type("aspire stop")
- .Enter()
- .WaitUntil(s => waitForStopResult2.Search(s).Count > 0 || waitForStoppedSuccessfully2.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .IncrementSequence(counter);
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ s.ContainsText("No running AppHost found") || s.ContainsText("AppHost stopped successfully."),
+ timeout: TimeSpan.FromMinutes(1), description: "AppHost stopped or no running AppHost");
+ await auto.WaitForAnyPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs
index b282ed04333..912f3185b4e 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs
@@ -26,62 +26,47 @@ public async Task StopNonInteractiveSingleAppHost()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- var waitForNoRunningAppHostsFound = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- sequenceBuilder.AspireNew("TestStopApp", counter);
+ await auto.AspireNewAsync("TestStopApp", counter);
// Navigate to the AppHost directory
- sequenceBuilder.Type("cd TestStopApp/TestStopApp.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd TestStopApp/TestStopApp.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Start the AppHost in the background using aspire start
- sequenceBuilder.Type("aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen to avoid matching old patterns
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Stop the AppHost using aspire stop --non-interactive --project (targets specific AppHost)
- sequenceBuilder.Type("aspire stop --non-interactive --project TestStopApp.AppHost.csproj")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop --non-interactive --project TestStopApp.AppHost.csproj");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Verify that stop --non-interactive handles no running AppHosts gracefully
- sequenceBuilder.Type("aspire stop --non-interactive")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("aspire stop --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -98,72 +83,57 @@ public async Task StopAllAppHostsFromAppHostDirectory()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- var waitForNoRunningAppHostsFound = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create first project
- sequenceBuilder.AspireNew("App1", counter);
+ await auto.AspireNewAsync("App1", counter);
// Clear screen before second project creation
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Create second project
- sequenceBuilder.AspireNew("App2", counter);
+ await auto.AspireNewAsync("App2", counter);
// Start first AppHost in background
- sequenceBuilder.Type("cd App1/App1.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd App1/App1.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen before starting second apphost
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Navigate back and start second AppHost in background
- sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd ../../App2/App2.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Stop all AppHosts from within an AppHost directory using --non-interactive --all
- sequenceBuilder.Type("aspire stop --non-interactive --all")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop --non-interactive --all");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Verify no AppHosts are running
- sequenceBuilder.Type("aspire stop --non-interactive")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("aspire stop --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -180,77 +150,62 @@ public async Task StopAllAppHostsFromUnrelatedDirectory()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
- var waitForNoRunningAppHostsFound = new CellPatternSearcher()
- .Find("No running AppHost found");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create first project
- sequenceBuilder.AspireNew("App1", counter);
+ await auto.AspireNewAsync("App1", counter);
// Clear screen before second project creation
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Create second project
- sequenceBuilder.AspireNew("App2", counter);
+ await auto.AspireNewAsync("App2", counter);
// Start first AppHost in background
- sequenceBuilder.Type("cd App1/App1.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd App1/App1.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen before starting second apphost
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Navigate back and start second AppHost in background
- sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd ../../App2/App2.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Navigate to workspace root (unrelated to any AppHost directory)
- sequenceBuilder.Type($"cd {CliE2ETestHelpers.ToContainerPath(workspace.WorkspaceRoot.FullName, workspace)}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(workspace.WorkspaceRoot.FullName, workspace)}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Stop all AppHosts from an unrelated directory using --non-interactive --all
- sequenceBuilder.Type("aspire stop --non-interactive --all")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop --non-interactive --all");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Verify no AppHosts are running
- sequenceBuilder.Type("aspire stop --non-interactive")
- .Enter()
- .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("aspire stop --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("No running AppHost found", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
@@ -267,77 +222,62 @@ public async Task StopNonInteractiveMultipleAppHostsShowsError()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern searchers for start/stop commands
- var waitForAppHostStartedSuccessfully = new CellPatternSearcher()
- .Find("AppHost started successfully.");
-
- var waitForMultipleAppHostsError = new CellPatternSearcher()
- .Find("Multiple AppHosts are running");
-
- var waitForAppHostStoppedSuccessfully = new CellPatternSearcher()
- .Find("AppHost stopped successfully.");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create first project
- sequenceBuilder.AspireNew("App1", counter);
+ await auto.AspireNewAsync("App1", counter);
// Clear screen before second project creation
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Create second project
- sequenceBuilder.AspireNew("App2", counter);
+ await auto.AspireNewAsync("App2", counter);
// Start first AppHost in background
- sequenceBuilder.Type("cd App1/App1.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd App1/App1.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen before starting second apphost
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Navigate back and start second AppHost in background
- sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire start")
- .Enter()
- .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cd ../../App2/App2.AppHost && aspire start");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Navigate to workspace root
- sequenceBuilder.Type($"cd {CliE2ETestHelpers.ToContainerPath(workspace.WorkspaceRoot.FullName, workspace)}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(workspace.WorkspaceRoot.FullName, workspace)}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Try to stop in non-interactive mode - should get an error about multiple AppHosts
- sequenceBuilder.Type("aspire stop --non-interactive")
- .Enter()
- .WaitUntil(s => waitForMultipleAppHostsError.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("aspire stop --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Multiple AppHosts are running", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
// Clear screen
- sequenceBuilder.ClearScreen(counter);
+ await auto.ClearScreenAsync(counter);
// Now use --all to stop all AppHosts
- sequenceBuilder.Type("aspire stop --all")
- .Enter()
- .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire stop --all");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
+ await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
- sequenceBuilder.Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
index e079472ce39..ef8ff4add5f 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
@@ -27,94 +27,72 @@ public async Task RestoreGeneratesSdkFiles()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- var waitingForPackageAdded = new CellPatternSearcher()
- .Find("The package Aspire.Hosting.");
-
- var waitingForRestoreSuccess = new CellPatternSearcher()
- .Find("SDK code restored successfully");
-
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
-
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
-
- // Enable polyglot support
- sequenceBuilder.EnablePolyglotSupport(counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Create a TypeScript AppHost
- sequenceBuilder
- .Type("aspire init --language typescript --non-interactive")
- .Enter()
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire init --language typescript --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 2: Add two integrations
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.Redis")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.SqlServer")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 3: Run aspire restore and verify success
- sequenceBuilder
- .Type("aspire restore")
- .Enter()
- .WaitUntil(s => waitingForRestoreSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire restore");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 4: Verify generated SDK files exist
- sequenceBuilder.ExecuteCallback(() =>
+ var modulesDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".modules");
+ if (!Directory.Exists(modulesDir))
{
- var modulesDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".modules");
- if (!Directory.Exists(modulesDir))
- {
- throw new InvalidOperationException($".modules directory was not created at {modulesDir}");
- }
+ throw new InvalidOperationException($".modules directory was not created at {modulesDir}");
+ }
- var expectedFiles = new[] { "aspire.ts", "base.ts", "transport.ts" };
- foreach (var file in expectedFiles)
+ var expectedFiles = new[] { "aspire.ts", "base.ts", "transport.ts" };
+ foreach (var file in expectedFiles)
+ {
+ var filePath = Path.Combine(modulesDir, file);
+ if (!File.Exists(filePath))
{
- var filePath = Path.Combine(modulesDir, file);
- if (!File.Exists(filePath))
- {
- throw new InvalidOperationException($"Expected generated file not found: {filePath}");
- }
-
- var content = File.ReadAllText(filePath);
- if (string.IsNullOrWhiteSpace(content))
- {
- throw new InvalidOperationException($"Generated file is empty: {filePath}");
- }
+ throw new InvalidOperationException($"Expected generated file not found: {filePath}");
}
- // Verify aspire.ts contains symbols from both integrations
- var aspireTs = File.ReadAllText(Path.Combine(modulesDir, "aspire.ts"));
- if (!aspireTs.Contains("addRedis"))
+ var content = File.ReadAllText(filePath);
+ if (string.IsNullOrWhiteSpace(content))
{
- throw new InvalidOperationException("aspire.ts does not contain addRedis from Aspire.Hosting.Redis");
+ throw new InvalidOperationException($"Generated file is empty: {filePath}");
}
- if (!aspireTs.Contains("addSqlServer"))
- {
- throw new InvalidOperationException("aspire.ts does not contain addSqlServer from Aspire.Hosting.SqlServer");
- }
- });
+ }
- sequenceBuilder
- .Type("exit")
- .Enter();
+ // Verify aspire.ts contains symbols from both integrations
+ var aspireTs = File.ReadAllText(Path.Combine(modulesDir, "aspire.ts"));
+ if (!aspireTs.Contains("addRedis"))
+ {
+ throw new InvalidOperationException("aspire.ts does not contain addRedis from Aspire.Hosting.Redis");
+ }
+ if (!aspireTs.Contains("addSqlServer"))
+ {
+ throw new InvalidOperationException("aspire.ts does not contain addSqlServer from Aspire.Hosting.SqlServer");
+ }
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await pendingRun;
}
@@ -131,85 +109,60 @@ public async Task RunWithMissingAwaitShowsHelpfulError()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- var waitingForPackageAdded = new CellPatternSearcher()
- .Find("The package Aspire.Hosting.");
-
- var waitingForRestoreSuccess = new CellPatternSearcher()
- .Find("SDK code restored successfully");
-
- var waitingForAppHostError = new CellPatternSearcher()
- .Find("❌ AppHost Error:");
-
- var waitingForAwaitHint = new CellPatternSearcher()
- .Find("Did you forget 'await'");
-
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ // PrepareEnvironment
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (isCI)
{
- sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireBundleEnvironment(counter);
- sequenceBuilder.VerifyAspireCliVersion(commitSha, counter);
+ await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter);
+ await auto.SourceAspireBundleEnvironmentAsync(counter);
+ await auto.VerifyAspireCliVersionAsync(commitSha, counter);
}
- sequenceBuilder.EnablePolyglotSupport(counter);
-
- sequenceBuilder
- .Type("aspire init --language typescript --non-interactive")
- .Enter()
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire init --language typescript --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.PostgreSQL")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder
- .Type("aspire restore")
- .Enter()
- .WaitUntil(s => waitingForRestoreSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire restore");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
- var newContent = """
- import { createBuilder } from './.modules/aspire.js';
+ var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
+ var newContent = """
+ import { createBuilder } from './.modules/aspire.js';
- const builder = await createBuilder();
+ const builder = await createBuilder();
- const postgres = builder.addPostgres("postgres");
- const db = postgres.addDatabase("db");
+ const postgres = builder.addPostgres("postgres");
+ const db = postgres.addDatabase("db");
- await builder.addContainer("consumer", "nginx")
- .withReference(db);
+ await builder.addContainer("consumer", "nginx")
+ .withReference(db);
- await builder.build().run();
- """;
+ await builder.build().run();
+ """;
- File.WriteAllText(appHostPath, newContent);
- });
+ File.WriteAllText(appHostPath, newContent);
- sequenceBuilder
- .Type("aspire run")
- .Enter()
- .WaitUntil(s =>
- waitingForAppHostError.Search(s).Count > 0 &&
- waitingForAwaitHint.Search(s).Count > 0,
- TimeSpan.FromMinutes(3))
- .WaitForAnyPrompt(counter)
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ s.ContainsText("❌ AppHost Error:") &&
+ s.ContainsText("Did you forget 'await'"),
+ timeout: TimeSpan.FromMinutes(3), description: "waiting for AppHost error with await hint");
+ await auto.WaitForAnyPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await pendingRun;
}
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs
index afcddc2e516..67b358aa9d0 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -25,109 +26,73 @@ public async Task CreateTypeScriptAppHostWithViteApp()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- // Pattern for language selection prompt
- var waitingForLanguageSelectionPrompt = new CellPatternSearcher()
- .Find("Which language would you like to use?");
-
- // Pattern for TypeScript language selected
- var waitingForTypeScriptSelected = new CellPatternSearcher()
- .Find("> TypeScript (Node.js)");
-
- // Pattern for waiting for apphost.ts creation success
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
-
- // Pattern for aspire add completion
- var waitingForPackageAdded = new CellPatternSearcher()
- .Find("The package Aspire.Hosting.");
-
- // Pattern for aspire run ready
- var waitForCtrlCMessage = new CellPatternSearcher()
- .Find("Press CTRL+C to stop the apphost and exit.");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- // Enable polyglot support feature flag
- sequenceBuilder.EnablePolyglotSupport(counter);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Create TypeScript AppHost using aspire init with interactive language selection
- sequenceBuilder
- .Type("aspire init")
- .Enter()
- .WaitUntil(s => waitingForLanguageSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- // Navigate down to "TypeScript (Node.js)" which is the 2nd option
- .Key(Hex1b.Input.Hex1bKey.DownArrow)
- .WaitUntil(s => waitingForTypeScriptSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .Enter() // select TypeScript
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .DeclineAgentInitPrompt(counter);
+ await auto.TypeAsync("aspire init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Which language would you like to use?", timeout: TimeSpan.FromSeconds(30));
+ // Navigate down to "TypeScript (Node.js)" which is the 2nd option
+ await auto.DownAsync();
+ await auto.WaitUntilTextAsync("> TypeScript (Node.js)", timeout: TimeSpan.FromSeconds(5));
+ await auto.EnterAsync(); // select TypeScript
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.DeclineAgentInitPromptAsync(counter);
// Step 2: Create a Vite app using npm create vite
// Using --template vanilla-ts for a minimal TypeScript Vite app
// Use -y to skip npm prompts and -- to pass args to create-vite
// Use --no-interactive to skip vite's interactive prompts (rolldown, install now, etc.)
- sequenceBuilder
- .Type("npm create -y vite@latest viteapp -- --template vanilla-ts --no-interactive")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("npm create -y vite@latest viteapp -- --template vanilla-ts --no-interactive");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 3: Install Vite app dependencies
- sequenceBuilder
- .Type("cd viteapp && npm install && cd ..")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("cd viteapp && npm install && cd ..");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 4: Add Aspire.Hosting.JavaScript package
// When channel is set (CI) and there's only one channel with one version,
// the version is auto-selected without prompting.
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.JavaScript")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Modify apphost.ts to add the Vite app
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
- var newContent = """
- // Aspire TypeScript AppHost
- // For more information, see: https://aspire.dev
+ var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
+ var newContent = """
+ // Aspire TypeScript AppHost
+ // For more information, see: https://aspire.dev
- import { createBuilder } from './.modules/aspire.js';
+ import { createBuilder } from './.modules/aspire.js';
- const builder = await createBuilder();
+ const builder = await createBuilder();
- // Add the Vite frontend application
- const viteApp = await builder.addViteApp("viteapp", "./viteapp");
+ // Add the Vite frontend application
+ const viteApp = await builder.addViteApp("viteapp", "./viteapp");
- await builder.build().run();
- """;
+ await builder.build().run();
+ """;
- File.WriteAllText(appHostPath, newContent);
- });
+ File.WriteAllText(appHostPath, newContent);
// Step 6: Run the apphost
- sequenceBuilder
- .Type("aspire run")
- .Enter()
- .WaitUntil(s => waitForCtrlCMessage.Search(s).Count > 0, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync("aspire run");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Press CTRL+C to stop the apphost and exit.", timeout: TimeSpan.FromMinutes(3));
// Step 7: Stop the apphost
- sequenceBuilder
- .Ctrl().Key(Hex1b.Input.Hex1bKey.C)
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs
index d19709298c7..995512c31d5 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs
@@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
+using Hex1b.Input;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
@@ -25,106 +26,77 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- var waitingForLanguagePrompt = new CellPatternSearcher()
- .Find("Which language would you like to use?");
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
- var waitingForTypeScriptSelected = new CellPatternSearcher()
- .Find("> TypeScript (Node.js)");
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
- var waitingForAppHostCreated = new CellPatternSearcher()
- .Find("Created apphost.ts");
+ await auto.EnablePolyglotSupportAsync(counter);
- var waitingForPackageAdded = new CellPatternSearcher()
- .Find("The package Aspire.Hosting.");
+ await auto.TypeAsync("aspire init");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Which language would you like to use?", timeout: TimeSpan.FromSeconds(30));
+ await auto.KeyAsync(Hex1bKey.DownArrow);
+ await auto.WaitUntilTextAsync("> TypeScript (Node.js)", timeout: TimeSpan.FromSeconds(5));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
+ await auto.DeclineAgentInitPromptAsync(counter);
- var waitingForRestoreSuccess = new CellPatternSearcher()
- .Find("SDK code restored successfully");
+ await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.TypeAsync("aspire restore");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
+ await auto.WaitForSuccessPromptAsync(counter);
- sequenceBuilder.EnablePolyglotSupport(counter);
+ var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
+ var newContent = """
+ import { createBuilder } from './.modules/aspire.js';
- sequenceBuilder
- .Type("aspire init")
- .Enter()
- .WaitUntil(s => waitingForLanguagePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30))
- .Key(Hex1b.Input.Hex1bKey.DownArrow)
- .WaitUntil(s => waitingForTypeScriptSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5))
- .Enter()
- .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .DeclineAgentInitPrompt(counter);
+ const builder = await createBuilder();
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.Docker")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ await builder.addDockerComposeEnvironment("compose");
- sequenceBuilder
- .Type("aspire add Aspire.Hosting.PostgreSQL")
- .Enter()
- .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter);
+ const postgres = await builder.addPostgres("postgres")
+ .publishAsDockerComposeService(async (_, svc) => {
+ await svc.name.set("postgres");
+ });
- sequenceBuilder
- .Type("aspire restore")
- .Enter()
- .WaitUntil(s => waitingForRestoreSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3))
- .WaitForSuccessPrompt(counter);
+ await postgres.addDatabase("db");
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
- var newContent = """
- import { createBuilder } from './.modules/aspire.js';
+ await builder.build().run();
+ """;
- const builder = await createBuilder();
+ File.WriteAllText(appHostPath, newContent);
- await builder.addDockerComposeEnvironment("compose");
+ await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- const postgres = await builder.addPostgres("postgres")
- .publishAsDockerComposeService(async (_, svc) => {
- await svc.name.set("postgres");
- });
+ await auto.TypeAsync("aspire publish -o artifacts --non-interactive");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(5));
- await postgres.addDatabase("db");
+ await auto.TypeAsync("ls -la artifacts/docker-compose.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- await builder.build().run();
- """;
+ await auto.TypeAsync("grep -F \"postgres:\" artifacts/docker-compose.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
- File.WriteAllText(appHostPath, newContent);
- });
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- sequenceBuilder
- .Type("unset ASPIRE_PLAYGROUND")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("aspire publish -o artifacts --non-interactive")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
-
- sequenceBuilder
- .Type("ls -la artifacts/docker-compose.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("grep -F \"postgres:\" artifacts/docker-compose.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter);
-
- sequenceBuilder
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await pendingRun;
}
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
index 243aa237a83..a9d1cf80cff 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
@@ -26,62 +26,55 @@ public async Task CreateAndRunTypeScriptStarterProject()
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
- var waitForDashboardCurlSuccess = new CellPatternSearcher()
- .Find("dashboard-http-200");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- sequenceBuilder.PrepareDockerEnvironment(counter, workspace);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
- sequenceBuilder.InstallAspireCliInDocker(installMode, counter);
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Step 1: Create project using aspire new, selecting the Express/React template
- sequenceBuilder.AspireNew("TsStarterApp", counter, template: AspireTemplate.ExpressReact);
+ await auto.AspireNewAsync("TsStarterApp", counter, template: AspireTemplate.ExpressReact);
// Step 1.5: Verify starter creation also restored the generated TypeScript SDK.
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp");
- var modulesDir = Path.Combine(projectRoot, ".modules");
+ var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp");
+ var modulesDir = Path.Combine(projectRoot, ".modules");
- if (!Directory.Exists(modulesDir))
- {
- throw new InvalidOperationException($".modules directory was not created at {modulesDir}");
- }
+ if (!Directory.Exists(modulesDir))
+ {
+ throw new InvalidOperationException($".modules directory was not created at {modulesDir}");
+ }
- var aspireModulePath = Path.Combine(modulesDir, "aspire.ts");
- if (!File.Exists(aspireModulePath))
- {
- throw new InvalidOperationException($"Expected generated file not found: {aspireModulePath}");
- }
- });
+ var aspireModulePath = Path.Combine(modulesDir, "aspire.ts");
+ if (!File.Exists(aspireModulePath))
+ {
+ throw new InvalidOperationException($"Expected generated file not found: {aspireModulePath}");
+ }
// Step 2: Navigate into the project and start it in background with JSON output
- sequenceBuilder
- .Type("cd TsStarterApp")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire start --format json | tee /tmp/aspire-start.json")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3))
- // Extract dashboard URL from JSON and curl it to verify it's reachable
- .Type("DASHBOARD_URL=$(sed -n 's/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https:\\/\\/localhost:[0-9]*\\).*/\\1/p' /tmp/aspire-start.json | head -1)")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" || echo 'dashboard-http-failed'")
- .Enter()
- .WaitUntil(s => waitForDashboardCurlSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(15))
- .WaitForSuccessPrompt(counter)
- .Type("aspire stop")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("exit")
- .Enter();
-
- var sequence = sequenceBuilder.Build();
-
- await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken);
+ await auto.TypeAsync("cd TsStarterApp");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("aspire start --format json | tee /tmp/aspire-start.json");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
+
+ // Extract dashboard URL from JSON and curl it to verify it's reachable
+ await auto.TypeAsync("DASHBOARD_URL=$(sed -n 's/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https:\\/\\/localhost:[0-9]*\\).*/\\1/p' /tmp/aspire-start.json | head -1)");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" || echo 'dashboard-http-failed'");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("dashboard-http-200", timeout: TimeSpan.FromSeconds(15));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("aspire stop");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
await pendingRun;
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
index 75e8286da7f..4d5e30f57dc 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
@@ -48,29 +48,20 @@ public async Task CreateStartWaitAndStopAspireProject()
// Start the AppHost in the background using aspire start
await auto.TypeAsync("aspire start");
await auto.EnterAsync();
- await auto.WaitUntilAsync(
- s => new CellPatternSearcher().Find("AppHost started successfully.").Search(s).Count > 0,
- timeout: TimeSpan.FromMinutes(3),
- description: "AppHost started successfully");
+ await auto.WaitUntilTextAsync("AppHost started successfully.", timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
// Wait for the webfrontend resource to be up (running).
// Use a longer timeout in Docker-in-Docker where container startup is slower.
await auto.TypeAsync("aspire wait webfrontend --status up --timeout 300");
await auto.EnterAsync();
- await auto.WaitUntilAsync(
- s => new CellPatternSearcher().Find("is up (running).").Search(s).Count > 0,
- timeout: TimeSpan.FromMinutes(6),
- description: "webfrontend resource is up (running)");
+ await auto.WaitUntilTextAsync("is up (running).", timeout: TimeSpan.FromMinutes(6));
await auto.WaitForSuccessPromptAsync(counter);
// Stop the AppHost using aspire stop
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
- await auto.WaitUntilAsync(
- s => new CellPatternSearcher().Find("AppHost stopped successfully.").Search(s).Count > 0,
- timeout: TimeSpan.FromMinutes(1),
- description: "AppHost stopped successfully");
+ await auto.WaitUntilTextAsync("AppHost stopped successfully.", timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
// Exit the shell
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs
index c24b504d934..9440e054d98 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs
@@ -66,53 +66,44 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost
output.WriteLine("Step 3: Creating single-file AppHost...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4: Add required packages
output.WriteLine("Step 4: Adding Azure Container Apps package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs with a long environment name and a container with volume.
// Use WithCompactResourceNaming() so the storage account name preserves the uniqueString.
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- var content = File.ReadAllText(appHostFilePath);
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Long env name (16 chars) would truncate uniqueString without compact naming
builder.AddAzureContainerAppEnvironment("my-long-env-name")
.WithCompactResourceNaming();
@@ -124,49 +115,46 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
+ content = content.Replace(buildRunPattern, replacement);
- // Suppress experimental diagnostic for WithCompactResourceNaming
- content = "#pragma warning disable ASPIREACANAMING001\n" + content;
+ // Suppress experimental diagnostic for WithCompactResourceNaming
+ content = "#pragma warning disable ASPIREACANAMING001\n" + content;
- File.WriteAllText(appHostFilePath, content);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs with long env name + compact naming + volume");
- });
+ output.WriteLine($"Modified apphost.cs with long env name + compact naming + volume");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy
output.WriteLine("Step 7: Deploying with compact naming...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify storage account was created and name contains uniqueString
output.WriteLine("Step 8: Verifying storage account naming...");
- sequenceBuilder
- .Type($"STORAGE_NAMES=$(az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv) && " +
- "echo \"Storage accounts: $STORAGE_NAMES\" && " +
- "STORAGE_COUNT=$(echo \"$STORAGE_NAMES\" | wc -l) && " +
- "echo \"Count: $STORAGE_COUNT\" && " +
- // Verify each storage name contains 'sv' (compact naming marker)
- "for name in $STORAGE_NAMES; do " +
- "if echo \"$name\" | grep -q 'sv'; then echo \"✅ $name uses compact naming\"; " +
- "else echo \"⚠️ $name does not use compact naming (may be ACR storage)\"; fi; " +
- "done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync(
+ $"STORAGE_NAMES=$(az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv) && " +
+ "echo \"Storage accounts: $STORAGE_NAMES\" && " +
+ "STORAGE_COUNT=$(echo \"$STORAGE_NAMES\" | wc -l) && " +
+ "echo \"Count: $STORAGE_COUNT\" && " +
+ // Verify each storage name contains 'sv' (compact naming marker)
+ "for name in $STORAGE_NAMES; do " +
+ "if echo \"$name\" | grep -q 'sv'; then echo \"✅ $name uses compact naming\"; " +
+ "else echo \"⚠️ $name does not use compact naming (may be ACR storage)\"; fi; " +
+ "done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit
- sequenceBuilder.Type("exit").Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs
index 3680b1d2fce..b992d67c05e 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs
@@ -65,29 +65,12 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForInitComplete = new CellPatternSearcher()
- .Find("Aspire initialization complete");
-
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForUpdateSuccessful = new CellPatternSearcher()
- .Find("Update successful");
-
- // aspire update prompts (used in Phase 2)
- var waitingForPerformUpdates = new CellPatternSearcher().Find("Perform updates?");
- var waitingForNugetConfigDir = new CellPatternSearcher().Find("NuGet.config file?");
- var waitingForApplyNugetConfig = new CellPatternSearcher().Find("Apply these changes");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// ============================================================
// Phase 1: Install GA CLI and deploy
@@ -97,51 +80,47 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell
output.WriteLine("Step 2: Backing up dev CLI and installing GA Aspire CLI...");
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .Type("cp ~/.aspire/bin/aspire /tmp/aspire-dev-backup && cp -r ~/.aspire/hives /tmp/aspire-hives-backup 2>/dev/null; echo 'dev CLI backed up'")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cp ~/.aspire/bin/aspire /tmp/aspire-dev-backup && cp -r ~/.aspire/hives /tmp/aspire-hives-backup 2>/dev/null; echo 'dev CLI backed up'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
}
- sequenceBuilder.InstallAspireCliRelease(counter);
+ await auto.InstallAspireCliReleaseAsync(counter);
// Step 3: Source CLI environment
output.WriteLine("Step 3: Configuring CLI environment...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
// Step 4: Log the GA CLI version
output.WriteLine("Step 4: Logging GA CLI version...");
- sequenceBuilder.Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Create single-file AppHost with GA CLI
output.WriteLine("Step 5: Creating single-file AppHost with GA CLI...");
- sequenceBuilder.Type("aspire init")
- .Enter()
- .Wait(TimeSpan.FromSeconds(5))
- .Enter()
- .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .DeclineAgentInitPrompt()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire init");
+ await auto.EnterAsync();
+ await auto.WaitAsync(TimeSpan.FromSeconds(5));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Aspire initialization complete", timeout: TimeSpan.FromMinutes(2));
+ await auto.DeclineAgentInitPromptAsync(counter);
// Step 6: Add ACA package using GA CLI (uses GA NuGet packages)
output.WriteLine("Step 6: Adding Azure Container Apps package (GA)...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter()
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 7: Modify apphost.cs with a short env name (fits within 24 chars with default naming)
// and a container with volume to trigger storage account creation
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- var content = File.ReadAllText(appHostFilePath);
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- // Use short name "env" (3 chars) so default naming works: "envstoragevolume" (16) + uniqueString fits in 24
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ // Use short name "env" (3 chars) so default naming works: "envstoragevolume" (16) + uniqueString fits in 24
+ var replacement = """
builder.AddAzureContainerAppEnvironment("env");
builder.AddContainer("worker", "mcr.microsoft.com/dotnet/samples", "aspnetapp")
@@ -150,32 +129,30 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine("Modified apphost.cs with short env name + volume (GA-compatible)");
- });
+ output.WriteLine("Modified apphost.cs with short env name + volume (GA-compatible)");
// Step 8: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Deploy with GA CLI
output.WriteLine("Step 9: First deployment with GA CLI...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 10: Record the storage account count after first deploy
output.WriteLine("Step 10: Recording storage account count after GA deploy...");
- sequenceBuilder
- .Type($"GA_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " +
- "echo \"GA deploy storage count: $GA_STORAGE_COUNT\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync(
+ $"GA_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " +
+ "echo \"GA deploy storage count: $GA_STORAGE_COUNT\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// ============================================================
// Phase 2: Upgrade to dev CLI and redeploy
@@ -186,35 +163,33 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell
{
output.WriteLine("Step 11: Restoring dev CLI from backup...");
// Restore the dev CLI and hive that we backed up before GA install
- sequenceBuilder
- .Type("cp -f /tmp/aspire-dev-backup ~/.aspire/bin/aspire && cp -rf /tmp/aspire-hives-backup/* ~/.aspire/hives/ 2>/dev/null; echo 'dev CLI restored'")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("cp -f /tmp/aspire-dev-backup ~/.aspire/bin/aspire && cp -rf /tmp/aspire-hives-backup/* ~/.aspire/hives/ 2>/dev/null; echo 'dev CLI restored'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Ensure the dev CLI uses the local channel (GA install may have changed it)
- sequenceBuilder
- .Type("aspire config set channel local --global 2>/dev/null; echo 'channel set'")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire config set channel local --global 2>/dev/null; echo 'channel set'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Re-source environment to pick up the dev CLI
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
// Run aspire update to upgrade the #:package directives in apphost.cs
// from the GA version to the dev build version. This ensures the actual
// deployment logic (naming, bicep generation) comes from the dev packages.
// aspire update shows 3 interactive prompts — handle each explicitly.
output.WriteLine("Step 11b: Updating project packages to dev version...");
- sequenceBuilder.Type("aspire update --channel local")
- .Enter()
- .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("aspire update --channel local");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("NuGet.config file?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Apply these changes", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Update successful", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
}
else
{
@@ -223,79 +198,77 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell
if (prNumber > 0)
{
output.WriteLine($"Step 11: Upgrading to dev CLI from PR #{prNumber}...");
- sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter);
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
// Update project packages to the PR version
output.WriteLine("Step 11b: Updating project packages to dev version...");
- sequenceBuilder.Type($"aspire update --channel pr-{prNumber}")
- .Enter()
- .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"aspire update --channel pr-{prNumber}");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("NuGet.config file?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Apply these changes", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Update successful", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
}
else
{
output.WriteLine("Step 11: No PR number available, using current CLI as 'dev'...");
// Still run aspire update to pick up whatever local packages are available
- sequenceBuilder.Type("aspire update")
- .Enter()
- .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .Enter()
- .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2))
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("aspire update");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("NuGet.config file?", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Apply these changes", timeout: TimeSpan.FromMinutes(2));
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Update successful", timeout: TimeSpan.FromMinutes(2));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
}
}
// Step 12: Log the dev CLI version and verify packages were updated
output.WriteLine("Step 12: Logging dev CLI version and verifying package update...");
- sequenceBuilder.Type("aspire --version")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Verify the #:package directives in apphost.cs were updated from GA version
- sequenceBuilder.Type("grep '#:package\\|#:sdk' apphost.cs")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync("grep '#:package\\|#:sdk' apphost.cs");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 13: Redeploy with dev packages — same apphost, NO compact naming
// The dev packages contain our changes but default naming is unchanged,
// so this should reuse the same resources created by the GA deploy.
output.WriteLine("Step 13: Redeploying with dev packages (no compact naming)...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 14: Verify no duplicate storage accounts
output.WriteLine("Step 14: Verifying no duplicate storage accounts...");
- sequenceBuilder
- .Type($"DEV_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " +
- "echo \"Dev deploy storage count: $DEV_STORAGE_COUNT\" && " +
- "echo \"GA deploy storage count: $GA_STORAGE_COUNT\" && " +
- "if [ \"$DEV_STORAGE_COUNT\" = \"$GA_STORAGE_COUNT\" ]; then " +
- "echo '✅ No duplicate storage accounts — default naming unchanged on upgrade'; " +
- "else " +
- "echo \"❌ Storage count changed from $GA_STORAGE_COUNT to $DEV_STORAGE_COUNT — NAMING REGRESSION\"; exit 1; " +
- "fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync(
+ $"DEV_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " +
+ "echo \"Dev deploy storage count: $DEV_STORAGE_COUNT\" && " +
+ "echo \"GA deploy storage count: $GA_STORAGE_COUNT\" && " +
+ "if [ \"$DEV_STORAGE_COUNT\" = \"$GA_STORAGE_COUNT\" ]; then " +
+ "echo '✅ No duplicate storage accounts — default naming unchanged on upgrade'; " +
+ "else " +
+ "echo \"❌ Storage count changed from $GA_STORAGE_COUNT to $DEV_STORAGE_COUNT — NAMING REGRESSION\"; exit 1; " +
+ "fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 15: Exit
- sequenceBuilder.Type("exit").Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs
index a5f1fb602b5..f2e524f04b5 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs
@@ -67,83 +67,67 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searchers for deployment completion
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
- var waitingForPipelineFailed = new CellPatternSearcher()
- .Find("PIPELINE FAILED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create starter project using aspire new with interactive prompts
output.WriteLine("Step 3: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Add Aspire.Hosting.Azure.ContainerRegistry package
output.WriteLine("Step 6: Adding Azure Container Registry hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerRegistry");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 7: Modify AppHost.cs to add custom ACR and ACA environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add custom Azure Container Registry and Container App Environment
var acr = builder.AddAzureContainerRegistry("myacr");
builder.AddAzureContainerAppEnvironment("infra").WithAzureContainerRegistry(acr);
@@ -151,52 +135,48 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
// Step 8: Navigate to AppHost project directory
output.WriteLine("Step 8: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Deploy to Azure Container Apps using aspire deploy
output.WriteLine("Step 10: Starting Azure Container Apps deployment...");
var pipelineSucceeded = false;
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s =>
- {
- if (waitingForPipelineSucceeded.Search(s).Count > 0)
- {
- pipelineSucceeded = true;
- return true;
- }
- return waitingForPipelineFailed.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(35))
- .ExecuteCallback(() =>
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("PIPELINE SUCCEEDED"))
{
- if (!pipelineSucceeded)
- {
- throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details.");
- }
- })
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ pipelineSucceeded = true;
+ return true;
+ }
+ return s.ContainsText("PIPELINE FAILED");
+ }, timeout: TimeSpan.FromMinutes(35), description: "pipeline succeeded or failed");
+
+ if (!pipelineSucceeded)
+ {
+ throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details.");
+ }
+
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 11: Extract deployment URLs and verify endpoints with retry
output.WriteLine("Step 11: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync(
+ $"RG_NAME=\"{resourceGroupName}\" && " +
"echo \"Resource group: $RG_NAME\" && " +
"if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
// Get external endpoints only (exclude .internal. which are not publicly accessible)
@@ -213,14 +193,14 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 12: Verify custom ACR contains container images
output.WriteLine("Step 12: Verifying container images in custom ACR...");
- sequenceBuilder
- .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
+ await auto.TypeAsync(
+ $"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
"echo \"ACR: $ACR_NAME\" && " +
"if [ -z \"$ACR_NAME\" ]; then echo \"❌ No ACR found in resource group\"; exit 1; fi && " +
"REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
@@ -231,17 +211,14 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken
"echo \" $repo: $TAGS\"; " +
"if [ -z \"$TAGS\" ]; then echo \" ❌ No tags for $repo\"; exit 1; fi; " +
"done && " +
- "echo \"✅ All container images verified in ACR\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ "echo \"✅ All container images verified in ACR\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 13: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs
index 358a70e11ab..36034f0d28b 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs
@@ -69,62 +69,52 @@ private async Task DeployWithInvalidLocation_ErrorOutputIsCleanCore(Cancellation
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineFailed = new CellPatternSearcher()
- .Find("PIPELINE FAILED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost
output.WriteLine("Step 3: Creating single-file AppHost...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4: Add Azure Container Apps package
output.WriteLine("Step 4: Adding Azure Container Apps package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Container App Environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- var content = File.ReadAllText(appHostFilePath);
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
builder.AddAzureContainerAppEnvironment("infra");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs with Azure Container App Environment");
- });
+ output.WriteLine($"Modified apphost.cs with Azure Container App Environment");
// Step 6: Set environment variables with an INVALID Azure location to induce failure.
// 'invalidlocation' is not a real Azure region, so provisioning will fail with
@@ -133,23 +123,21 @@ private async Task DeployWithInvalidLocation_ErrorOutputIsCleanCore(Cancellation
// sets Azure__Location=westus3 at the job level, and on Linux env vars are
// case-sensitive. Then set the invalid location with both casings to be safe.
output.WriteLine("Step 6: Setting invalid Azure location to induce failure...");
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && unset Azure__Location && export AZURE__LOCATION=invalidlocation && export Azure__Location=invalidlocation && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && unset Azure__Location && export AZURE__LOCATION=invalidlocation && export Azure__Location=invalidlocation && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy (expecting failure) and capture output to a file
output.WriteLine("Step 7: Starting deployment with invalid location (expecting failure)...");
- sequenceBuilder
- .Type($"aspire deploy --clear-cache 2>&1 | tee {deployOutputFile}")
- .Enter()
- .WaitUntil(s => waitingForPipelineFailed.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForAnyPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync($"aspire deploy --clear-cache 2>&1 | tee {deployOutputFile}");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE FAILED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Exit terminal
- sequenceBuilder.Type("exit").Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
// Step 9: Read captured output and verify error messages are clean
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs
index 56f03900e7b..901048ded6d 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs
@@ -77,97 +77,79 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searchers for deployment completion
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
- var waitingForPipelineFailed = new CellPatternSearcher()
- .Find("PIPELINE FAILED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Pre-create resource group and ACR via az CLI
output.WriteLine("Step 3: Pre-creating resource group and ACR...");
- sequenceBuilder
- .Type($"az group create --name {resourceGroupName} --location westus3 -o none")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az group create --name {resourceGroupName} --location westus3 -o none");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
- sequenceBuilder
- .Type($"az acr create --name {acrName} --resource-group {resourceGroupName} --sku Basic --admin-enabled false -o none")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync($"az acr create --name {acrName} --resource-group {resourceGroupName} --sku Basic --admin-enabled false -o none");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
output.WriteLine($"Pre-created ACR: {acrName} in resource group: {resourceGroupName}");
// Step 4: Create starter project using aspire new with interactive prompts
output.WriteLine("Step 4: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 5: Navigate to project directory
output.WriteLine("Step 5: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 6: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 6: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 7: Add Aspire.Hosting.Azure.ContainerRegistry package
output.WriteLine("Step 7: Adding Azure Container Registry hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerRegistry");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 8: Modify AppHost.cs to reference the existing ACR
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Reference existing Azure Container Registry via parameter
var acrName = builder.AddParameter("acrName");
var acr = builder.AddAzureContainerRegistry("existingacr").AsExisting(acrName, null);
@@ -176,56 +158,52 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
// Step 9: Navigate to AppHost project directory
output.WriteLine("Step 9: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Set environment variables for deployment including ACR parameter
- sequenceBuilder.Type(
- $"unset ASPIRE_PLAYGROUND && " +
- $"export AZURE__LOCATION=westus3 && " +
- $"export AZURE__RESOURCEGROUP={resourceGroupName} && " +
- $"export Parameters__acrName={acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync(
+ $"unset ASPIRE_PLAYGROUND && " +
+ $"export AZURE__LOCATION=westus3 && " +
+ $"export AZURE__RESOURCEGROUP={resourceGroupName} && " +
+ $"export Parameters__acrName={acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 11: Deploy to Azure Container Apps using aspire deploy
output.WriteLine("Step 11: Starting Azure Container Apps deployment...");
var pipelineSucceeded = false;
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s =>
- {
- if (waitingForPipelineSucceeded.Search(s).Count > 0)
- {
- pipelineSucceeded = true;
- return true;
- }
- return waitingForPipelineFailed.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(35))
- .ExecuteCallback(() =>
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("PIPELINE SUCCEEDED"))
{
- if (!pipelineSucceeded)
- {
- throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details.");
- }
- })
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ pipelineSucceeded = true;
+ return true;
+ }
+ return s.ContainsText("PIPELINE FAILED");
+ }, timeout: TimeSpan.FromMinutes(35), description: "pipeline succeeded or failed");
+
+ if (!pipelineSucceeded)
+ {
+ throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details.");
+ }
+
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 12: Extract deployment URLs and verify endpoints with retry
output.WriteLine("Step 12: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync(
+ $"RG_NAME=\"{resourceGroupName}\" && " +
"echo \"Resource group: $RG_NAME\" && " +
"if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
"urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
@@ -241,14 +219,14 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 13: Verify the pre-existing ACR contains container images
output.WriteLine("Step 13: Verifying container images in pre-existing ACR...");
- sequenceBuilder
- .Type($"echo \"ACR: {acrName}\" && " +
+ await auto.TypeAsync(
+ $"echo \"ACR: {acrName}\" && " +
$"REPOS=$(az acr repository list --name \"{acrName}\" -o tsv) && " +
"echo \"Repositories: $REPOS\" && " +
"if [ -z \"$REPOS\" ]; then echo \"❌ No container images found in ACR\"; exit 1; fi && " +
@@ -257,17 +235,14 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok
"echo \" $repo: $TAGS\"; " +
"if [ -z \"$TAGS\" ]; then echo \" ❌ No tags for $repo\"; exit 1; fi; " +
"done && " +
- "echo \"✅ All container images verified in ACR\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ "echo \"✅ All container images verified in ACR\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 14: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs
index 3a05a02b21a..dabee651860 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs
@@ -68,20 +68,12 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
// The workflow builds and installs the CLI to ~/.aspire/bin before running tests
@@ -90,118 +82,108 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
// Source the CLI environment (sets PATH and other env vars)
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create starter project using aspire new with interactive prompts
output.WriteLine("Step 3: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify AppHost.cs to add Azure Container App Environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Insert the Azure Container App Environment before builder.Build().Run();
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ // Insert the Azure Container App Environment before builder.Build().Run();
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container App Environment for deployment
builder.AddAzureContainerAppEnvironment("infra");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
// Step 7: Navigate to AppHost project directory
output.WriteLine("Step 6: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: Set environment variables for deployment
// - Unset ASPIRE_PLAYGROUND to avoid conflicts
// - Set Azure location
// - Set AZURE__RESOURCEGROUP to use our unique resource group name
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Deploy to Azure Container Apps using aspire deploy
// Use --clear-cache to ensure fresh deployment without cached location from previous runs
output.WriteLine("Step 7: Starting Azure Container Apps deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- // Wait for pipeline to complete successfully
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ // Wait for pipeline to complete successfully
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 10: Extract deployment URLs and verify endpoints with retry
// Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds)
output.WriteLine("Step 8: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
- "echo \"Resource group: $RG_NAME\" && " +
- "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
- // Get external endpoints only (exclude .internal. which are not publicly accessible)
- "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
- "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " +
- "failed=0 && " +
- "for url in $urls; do " +
- "echo \"Checking https://$url...\"; " +
- "success=0; " +
- "for i in $(seq 1 18); do " +
- "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " +
- "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
- "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
- "done; " +
- "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
- "done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync(
+ $"RG_NAME=\"{resourceGroupName}\" && " +
+ "echo \"Resource group: $RG_NAME\" && " +
+ "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
+ // Get external endpoints only (exclude .internal. which are not publicly accessible)
+ "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
+ "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " +
+ "failed=0 && " +
+ "for url in $urls; do " +
+ "echo \"Checking https://$url...\"; " +
+ "success=0; " +
+ "for i in $(seq 1 18); do " +
+ "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " +
+ "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
+ "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
+ "done; " +
+ "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
+ "done && " +
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 11: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcrPurgeTaskDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcrPurgeTaskDeploymentTests.cs
index 5ea8795ea28..d8b139cd759 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AcrPurgeTaskDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcrPurgeTaskDeploymentTests.cs
@@ -66,69 +66,54 @@ private async Task DeployPythonStarterWithPurgeTaskCore(CancellationToken cancel
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searchers for deployment completion
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
- var waitingForPipelineFailed = new CellPatternSearcher()
- .Find("PIPELINE FAILED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create Python FastAPI project using aspire new
output.WriteLine("Step 3: Creating Python FastAPI project...");
- sequenceBuilder.AspireNew(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify apphost.cs to add ACA environment with purge task
// Python template uses single-file AppHost (apphost.cs in project root)
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostFilePath = Path.Combine(projectDir, "apphost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostFilePath = Path.Combine(projectDir, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container App Environment and configure ACR purge task
var infra = builder.AddAzureContainerAppEnvironment("infra");
// Schedule once a month so it never fires during the test; the task is triggered manually via az acr task run
@@ -138,157 +123,137 @@ private async Task DeployPythonStarterWithPurgeTaskCore(CancellationToken cancel
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified apphost.cs at: {appHostFilePath}");
// Step 7: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: First deployment to Azure
output.WriteLine("Step 8: Starting first Azure deployment...");
var pipelineSucceeded = false;
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s =>
- {
- if (waitingForPipelineSucceeded.Search(s).Count > 0)
- {
- pipelineSucceeded = true;
- return true;
- }
- return waitingForPipelineFailed.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(30))
- .ExecuteCallback(() =>
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("PIPELINE SUCCEEDED"))
{
- if (!pipelineSucceeded)
- {
- throw new InvalidOperationException("First deployment pipeline failed. Check the terminal output for details.");
- }
- })
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ pipelineSucceeded = true;
+ return true;
+ }
+ return s.ContainsText("PIPELINE FAILED");
+ }, timeout: TimeSpan.FromMinutes(30), description: "pipeline succeeded or failed");
+
+ if (!pipelineSucceeded)
+ {
+ throw new InvalidOperationException("First deployment pipeline failed. Check the terminal output for details.");
+ }
+
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 9: Get the ACR name and count tags before second deploy
output.WriteLine("Step 9: Getting ACR name and counting initial tags...");
- sequenceBuilder
- .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
- "echo \"ACR: $ACR_NAME\" && " +
- "if [ -z \"$ACR_NAME\" ]; then echo \"❌ No ACR found in resource group\"; exit 1; fi && " +
- "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
- "echo \"Repositories after first deploy:\" && " +
- "for repo in $REPOS; do " +
- "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
- "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
- "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
- "done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
+ "echo \"ACR: $ACR_NAME\" && " +
+ "if [ -z \"$ACR_NAME\" ]; then echo \"❌ No ACR found in resource group\"; exit 1; fi && " +
+ "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
+ "echo \"Repositories after first deploy:\" && " +
+ "for repo in $REPOS; do " +
+ "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
+ "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
+ "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
+ "done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 10: Modify Python code to guarantee a new container image is pushed on second deploy
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var mainPyPath = Path.Combine(projectDir, "app", "main.py");
+ var projectDir2 = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var mainPyPath = Path.Combine(projectDir2, "app", "main.py");
- output.WriteLine($"Modifying {mainPyPath} to force new image...");
+ output.WriteLine($"Modifying {mainPyPath} to force new image...");
- var content = File.ReadAllText(mainPyPath);
- content += "\n# Force new image for E2E purge test\n";
- File.WriteAllText(mainPyPath, content);
+ var content2 = File.ReadAllText(mainPyPath);
+ content2 += "\n# Force new image for E2E purge test\n";
+ File.WriteAllText(mainPyPath, content2);
- output.WriteLine("Modified main.py to force a new container image build");
- });
+ output.WriteLine("Modified main.py to force a new container image build");
// Step 11: Second deployment to push new images
- // Clear the terminal so the CellPatternSearcher doesn't match "PIPELINE SUCCEEDED" from the first deploy
+ // Clear the terminal so WaitUntilTextAsync doesn't match "PIPELINE SUCCEEDED" from the first deploy
output.WriteLine("Step 11: Starting second Azure deployment...");
- var waitingForPipelineSucceeded2 = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
- var waitingForPipelineFailed2 = new CellPatternSearcher()
- .Find("PIPELINE FAILED");
-
var pipeline2Succeeded = false;
- sequenceBuilder
- .Type("export TERM=xterm && clear")
- .Enter()
- .WaitForSuccessPrompt(counter)
- .Type("aspire deploy")
- .Enter()
- .WaitUntil(s =>
- {
- if (waitingForPipelineSucceeded2.Search(s).Count > 0)
- {
- pipeline2Succeeded = true;
- return true;
- }
- return waitingForPipelineFailed2.Search(s).Count > 0;
- }, TimeSpan.FromMinutes(30))
- .ExecuteCallback(() =>
+ await auto.TypeAsync("export TERM=xterm && clear");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ await auto.TypeAsync("aspire deploy");
+ await auto.EnterAsync();
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("PIPELINE SUCCEEDED"))
{
- if (!pipeline2Succeeded)
- {
- throw new InvalidOperationException("Second deployment pipeline failed. Check the terminal output for details.");
- }
- })
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ pipeline2Succeeded = true;
+ return true;
+ }
+ return s.ContainsText("PIPELINE FAILED");
+ }, timeout: TimeSpan.FromMinutes(30), description: "pipeline succeeded or failed");
+
+ if (!pipeline2Succeeded)
+ {
+ throw new InvalidOperationException("Second deployment pipeline failed. Check the terminal output for details.");
+ }
+
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 12: Verify there are now multiple tags (from both deploys)
output.WriteLine("Step 12: Verifying multiple tags exist after second deploy...");
- sequenceBuilder
- .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
- "echo \"ACR: $ACR_NAME\" && " +
- "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
- "echo \"Repositories after second deploy:\" && " +
- "for repo in $REPOS; do " +
- "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
- "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
- "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
- "done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
+ "echo \"ACR: $ACR_NAME\" && " +
+ "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
+ "echo \"Repositories after second deploy:\" && " +
+ "for repo in $REPOS; do " +
+ "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
+ "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
+ "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
+ "done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 13: Run the purge task manually to trigger image cleanup
// az acr task run is synchronous - it waits for completion and streams output
output.WriteLine("Step 13: Running ACR purge task...");
- sequenceBuilder
- .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
- "echo \"Running purge task on ACR: $ACR_NAME\" && " +
- "az acr task run --name purgeOldImages --registry \"$ACR_NAME\"")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
+ "echo \"Running purge task on ACR: $ACR_NAME\" && " +
+ "az acr task run --name purgeOldImages --registry \"$ACR_NAME\"");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 14: Verify images were purged - only 1 tag should remain per repo
output.WriteLine("Step 14: Verifying images were purged...");
- sequenceBuilder
- .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
- "echo \"ACR: $ACR_NAME\" && " +
- "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
- "if [ -z \"$REPOS\" ]; then echo \"❌ No repositories found in ACR - cannot verify purge\"; exit 1; fi && " +
- "echo \"Repositories after purge:\" && " +
- "all_ok=1 && " +
- "for repo in $REPOS; do " +
- "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
- "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
- "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
- "if [ \"$TAG_COUNT\" -gt 1 ]; then echo \" ❌ Expected at most 1 tag after purge, got $TAG_COUNT\"; all_ok=0; fi; " +
- "done && " +
- "if [ \"$all_ok\" -eq 1 ]; then echo \"✅ Purge task verified - only 1 tag remains per repo\"; " +
- "else echo \"❌ Purge task did not clean up as expected\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " +
+ "echo \"ACR: $ACR_NAME\" && " +
+ "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " +
+ "if [ -z \"$REPOS\" ]; then echo \"❌ No repositories found in ACR - cannot verify purge\"; exit 1; fi && " +
+ "echo \"Repositories after purge:\" && " +
+ "all_ok=1 && " +
+ "for repo in $REPOS; do " +
+ "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " +
+ "TAG_COUNT=$(echo \"$TAGS\" | wc -l); " +
+ "echo \" $repo: $TAG_COUNT tag(s) - $TAGS\"; " +
+ "if [ \"$TAG_COUNT\" -gt 1 ]; then echo \" ❌ Expected at most 1 tag after purge, got $TAG_COUNT\"; all_ok=0; fi; " +
+ "done && " +
+ "if [ \"$all_ok\" -eq 1 ]; then echo \"✅ Purge task verified - only 1 tag remains per repo\"; " +
+ "else echo \"❌ Purge task did not clean up as expected\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 15: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs
index 6106f7f724c..8afd919004c 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs
@@ -76,95 +76,82 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation
var pendingRun = terminal.RunAsync(cancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Project name for the Aspire application
var projectName = "AksStarter";
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Register required resource providers
// AKS requires Microsoft.ContainerService and Microsoft.ContainerRegistry
output.WriteLine("Step 2: Registering required resource providers...");
- sequenceBuilder
- .Type("az provider register --namespace Microsoft.ContainerService --wait && " +
- "az provider register --namespace Microsoft.ContainerRegistry --wait")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("az provider register --namespace Microsoft.ContainerService --wait && " +
+ "az provider register --namespace Microsoft.ContainerRegistry --wait");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 3: Create resource group
output.WriteLine("Step 3: Creating resource group...");
- sequenceBuilder
- .Type($"az group create --name {resourceGroupName} --location westus3 --output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az group create --name {resourceGroupName} --location westus3 --output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 4: Create Azure Container Registry
output.WriteLine("Step 4: Creating Azure Container Registry...");
- sequenceBuilder
- .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
// Step 4b: Login to ACR immediately (before AKS creation which takes 10-15 min).
// The OIDC federated token expires after ~5 minutes, so we must authenticate with
// ACR while it's still fresh. Docker credentials persist in ~/.docker/config.json.
output.WriteLine("Step 4b: Logging into Azure Container Registry (early, before token expires)...");
- sequenceBuilder
- .Type($"az acr login --name {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az acr login --name {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 5: Create AKS cluster with ACR attached
// Using minimal configuration: 1 node, Standard_D2s_v3 (widely available with quota)
output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)...");
- sequenceBuilder
- .Type($"az aks create " +
- $"--resource-group {resourceGroupName} " +
- $"--name {clusterName} " +
- $"--node-count 1 " +
- $"--node-vm-size Standard_D2s_v3 " +
- $"--generate-ssh-keys " +
- $"--attach-acr {acrName} " +
- $"--enable-managed-identity " +
- $"--output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20));
+ await auto.TypeAsync($"az aks create " +
+ $"--resource-group {resourceGroupName} " +
+ $"--name {clusterName} " +
+ $"--node-count 1 " +
+ $"--node-vm-size Standard_D2s_v3 " +
+ $"--generate-ssh-keys " +
+ $"--attach-acr {acrName} " +
+ $"--enable-managed-identity " +
+ $"--output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(20));
// Step 6: Ensure AKS can pull from ACR (update attachment to ensure role propagation)
// ReconcilingAddons can take several minutes after role assignment updates
output.WriteLine("Step 6: Verifying AKS-ACR integration...");
- sequenceBuilder
- .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 7: Configure kubectl credentials
output.WriteLine("Step 7: Configuring kubectl credentials...");
- sequenceBuilder
- .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 8: Verify kubectl connectivity
output.WriteLine("Step 8: Verifying kubectl connectivity...");
- sequenceBuilder
- .Type("kubectl get nodes")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get nodes");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Verify cluster is healthy
output.WriteLine("Step 9: Verifying cluster health...");
- sequenceBuilder
- .Type("kubectl cluster-info")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl cluster-info");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// ===== PHASE 2: Create Aspire Project and Generate Helm Charts =====
@@ -172,202 +159,179 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 11: Create starter project using aspire new with interactive prompts
output.WriteLine("Step 11: Creating Aspire starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 12: Navigate to project directory
output.WriteLine("Step 12: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 13: Add Aspire.Hosting.Kubernetes package
output.WriteLine("Step 13: Adding Kubernetes hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 14: Modify AppHost.cs to add Kubernetes environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Insert the Kubernetes environment before builder.Build().Run();
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ // Insert the Kubernetes environment before builder.Build().Run();
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Kubernetes environment for deployment
builder.AddKubernetesEnvironment("k8s");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
+ content = content.Replace(buildRunPattern, replacement);
- // Add required pragma to suppress experimental warning
- if (!content.Contains("#pragma warning disable ASPIREPIPELINES001"))
- {
- content = "#pragma warning disable ASPIREPIPELINES001\n" + content;
- }
+ // Add required pragma to suppress experimental warning
+ if (!content.Contains("#pragma warning disable ASPIREPIPELINES001"))
+ {
+ content = "#pragma warning disable ASPIREPIPELINES001\n" + content;
+ }
- File.WriteAllText(appHostFilePath, content);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment");
- });
+ output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment");
// Step 15: Navigate to AppHost project directory
output.WriteLine("Step 15: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 16: Re-login to ACR after AKS creation to refresh Docker credentials.
// The initial login (Step 4b) may have expired during the 10-15 min AKS provisioning
// because OIDC federated tokens have a short lifetime (~5 min).
output.WriteLine("Step 16: Refreshing ACR login...");
- sequenceBuilder
- .Type($"az acr login --name {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az acr login --name {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 17: Build and push container images to ACR
// The starter template creates webfrontend and apiservice projects
output.WriteLine("Step 17: Building and pushing container images to ACR...");
- sequenceBuilder
- .Type($"cd .. && " +
- $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " +
- $"/t:PublishContainer " +
- $"/p:ContainerRegistry={acrName}.azurecr.io " +
- $"/p:ContainerImageName=webfrontend " +
- $"/p:ContainerImageTag=latest")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
-
- sequenceBuilder
- .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " +
- $"/t:PublishContainer " +
- $"/p:ContainerRegistry={acrName}.azurecr.io " +
- $"/p:ContainerImageName=apiservice " +
- $"/p:ContainerImageTag=latest")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"cd .. && " +
+ $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " +
+ $"/t:PublishContainer " +
+ $"/p:ContainerRegistry={acrName}.azurecr.io " +
+ $"/p:ContainerImageName=webfrontend " +
+ $"/p:ContainerImageTag=latest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
+
+ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " +
+ $"/t:PublishContainer " +
+ $"/p:ContainerRegistry={acrName}.azurecr.io " +
+ $"/p:ContainerImageName=apiservice " +
+ $"/p:ContainerImageTag=latest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Navigate back to AppHost directory
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 18: Run aspire publish to generate Helm charts
output.WriteLine("Step 18: Running aspire publish to generate Helm charts...");
- sequenceBuilder
- .Type($"aspire publish --output-path ../charts")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10));
+ await auto.TypeAsync($"aspire publish --output-path ../charts");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10));
// Step 19: Verify Helm chart was generated
output.WriteLine("Step 19: Verifying Helm chart generation...");
- sequenceBuilder
- .Type("ls -la ../charts && cat ../charts/Chart.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("ls -la ../charts && cat ../charts/Chart.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// ===== PHASE 3: Deploy to AKS and Verify =====
// Step 20: Verify ACR role assignment has propagated before deploying
output.WriteLine("Step 20: Verifying AKS can pull from ACR...");
- sequenceBuilder
- .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
// Step 21: Deploy Helm chart to AKS with ACR image overrides
// Image values use the path: parameters.._image
output.WriteLine("Step 21: Deploying Helm chart to AKS...");
- sequenceBuilder
- .Type($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " +
- $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " +
- $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12));
+ await auto.TypeAsync($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " +
+ $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " +
+ $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(12));
// Step 22: Wait for pods to be ready
// Pods may need time to pull images from ACR and start the application
output.WriteLine("Step 22: Waiting for pods to be ready...");
- sequenceBuilder
- .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=300s")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(6));
+ await auto.TypeAsync("kubectl wait --for=condition=ready pod --all -n default --timeout=300s");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
// Step 23: Verify pods are running
output.WriteLine("Step 23: Verifying pods are running...");
- sequenceBuilder
- .Type("kubectl get pods -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get pods -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 24: Verify deployments are healthy
output.WriteLine("Step 24: Verifying deployments...");
- sequenceBuilder
- .Type("kubectl get deployments -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get deployments -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 25: Verify apiservice is serving traffic via port-forward
// Use /weatherforecast (the actual API endpoint) since /health is only available in Development
output.WriteLine("Step 25: Verifying apiservice endpoint...");
- sequenceBuilder
- .Type("kubectl port-forward svc/apiservice-service 18080:8080 &")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10))
- .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("kubectl port-forward svc/apiservice-service 18080:8080 &");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 26: Verify webfrontend is serving traffic via port-forward
output.WriteLine("Step 26: Verifying webfrontend endpoint...");
- sequenceBuilder
- .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10))
- .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("kubectl port-forward svc/webfrontend-service 18081:8080 &");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 27: Clean up port-forwards
output.WriteLine("Step 27: Cleaning up port-forwards...");
- sequenceBuilder
- .Type("kill %1 %2 2>/dev/null; true")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("kill %1 %2 2>/dev/null; true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
// Step 28: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs
index 0c66dc0217e..d3037746887 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs
@@ -77,11 +77,7 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can
var pendingRun = terminal.RunAsync(cancellationToken);
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
-
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
var projectName = "AksRedis";
@@ -89,82 +85,73 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Register required resource providers
output.WriteLine("Step 2: Registering required resource providers...");
- sequenceBuilder
- .Type("az provider register --namespace Microsoft.ContainerService --wait && " +
- "az provider register --namespace Microsoft.ContainerRegistry --wait")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync("az provider register --namespace Microsoft.ContainerService --wait && " +
+ "az provider register --namespace Microsoft.ContainerRegistry --wait");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 3: Create resource group
output.WriteLine("Step 3: Creating resource group...");
- sequenceBuilder
- .Type($"az group create --name {resourceGroupName} --location westus3 --output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az group create --name {resourceGroupName} --location westus3 --output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 4: Create Azure Container Registry
output.WriteLine("Step 4: Creating Azure Container Registry...");
- sequenceBuilder
- .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
// Step 4b: Login to ACR immediately (before AKS creation which takes 10-15 min).
// The OIDC federated token expires after ~5 minutes, so we must authenticate with
// ACR while it's still fresh. Docker credentials persist in ~/.docker/config.json.
output.WriteLine("Step 4b: Logging into Azure Container Registry (early, before token expires)...");
- sequenceBuilder
- .Type($"az acr login --name {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az acr login --name {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 5: Create AKS cluster with ACR attached
output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)...");
- sequenceBuilder
- .Type($"az aks create " +
- $"--resource-group {resourceGroupName} " +
- $"--name {clusterName} " +
- $"--node-count 1 " +
- $"--node-vm-size Standard_D2s_v3 " +
- $"--generate-ssh-keys " +
- $"--attach-acr {acrName} " +
- $"--enable-managed-identity " +
- $"--output table")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20));
+ await auto.TypeAsync($"az aks create " +
+ $"--resource-group {resourceGroupName} " +
+ $"--name {clusterName} " +
+ $"--node-count 1 " +
+ $"--node-vm-size Standard_D2s_v3 " +
+ $"--generate-ssh-keys " +
+ $"--attach-acr {acrName} " +
+ $"--enable-managed-identity " +
+ $"--output table");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(20));
// Step 6: Ensure AKS can pull from ACR
// ReconcilingAddons can take several minutes after role assignment updates
output.WriteLine("Step 6: Verifying AKS-ACR integration...");
- sequenceBuilder
- .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 7: Configure kubectl credentials
output.WriteLine("Step 7: Configuring kubectl credentials...");
- sequenceBuilder
- .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 8: Verify kubectl connectivity
output.WriteLine("Step 8: Verifying kubectl connectivity...");
- sequenceBuilder
- .Type("kubectl get nodes")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get nodes");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Verify cluster health
output.WriteLine("Step 9: Verifying cluster health...");
- sequenceBuilder
- .Type("kubectl cluster-info")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl cluster-info");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// ===== PHASE 2: Create Aspire Project with Redis and Generate Helm Charts =====
@@ -172,133 +159,120 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 11: Create starter project with Redis enabled
output.WriteLine("Step 11: Creating Aspire starter project with Redis...");
- sequenceBuilder.AspireNew(projectName, counter);
+ await auto.AspireNewAsync(projectName, counter);
// Step 12: Navigate to project directory
output.WriteLine("Step 12: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 13: Add Aspire.Hosting.Kubernetes package
output.WriteLine("Step 13: Adding Kubernetes hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 14: Modify AppHost.cs to add Kubernetes environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Insert the Kubernetes environment before builder.Build().Run();
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ // Insert the Kubernetes environment before builder.Build().Run();
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Kubernetes environment for deployment
builder.AddKubernetesEnvironment("k8s");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
+ content = content.Replace(buildRunPattern, replacement);
- // Add required pragma to suppress experimental warning
- if (!content.Contains("#pragma warning disable ASPIREPIPELINES001"))
- {
- content = "#pragma warning disable ASPIREPIPELINES001\n" + content;
- }
+ // Add required pragma to suppress experimental warning
+ if (!content.Contains("#pragma warning disable ASPIREPIPELINES001"))
+ {
+ content = "#pragma warning disable ASPIREPIPELINES001\n" + content;
+ }
- File.WriteAllText(appHostFilePath, content);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment");
- });
+ output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment");
// Step 15: Navigate to AppHost project directory
output.WriteLine("Step 15: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 16: Re-login to ACR after AKS creation to refresh Docker credentials.
// The initial login (Step 4b) may have expired during the 10-15 min AKS provisioning
// because OIDC federated tokens have a short lifetime (~5 min).
output.WriteLine("Step 16: Refreshing ACR login...");
- sequenceBuilder
- .Type($"az acr login --name {acrName}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az acr login --name {acrName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 17: Build and push container images to ACR
// Only project resources need to be built — Redis uses a public container image
output.WriteLine("Step 17: Building and pushing container images to ACR...");
- sequenceBuilder
- .Type($"cd .. && " +
- $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " +
- $"/t:PublishContainer " +
- $"/p:ContainerRegistry={acrName}.azurecr.io " +
- $"/p:ContainerImageName=webfrontend " +
- $"/p:ContainerImageTag=latest")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
-
- sequenceBuilder
- .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " +
- $"/t:PublishContainer " +
- $"/p:ContainerRegistry={acrName}.azurecr.io " +
- $"/p:ContainerImageName=apiservice " +
- $"/p:ContainerImageTag=latest")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"cd .. && " +
+ $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " +
+ $"/t:PublishContainer " +
+ $"/p:ContainerRegistry={acrName}.azurecr.io " +
+ $"/p:ContainerImageName=webfrontend " +
+ $"/p:ContainerImageTag=latest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
+
+ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " +
+ $"/t:PublishContainer " +
+ $"/p:ContainerRegistry={acrName}.azurecr.io " +
+ $"/p:ContainerImageName=apiservice " +
+ $"/p:ContainerImageTag=latest");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Navigate back to AppHost directory
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 18: Run aspire publish to generate Helm charts
output.WriteLine("Step 18: Running aspire publish to generate Helm charts...");
- sequenceBuilder
- .Type($"aspire publish --output-path ../charts")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10));
+ await auto.TypeAsync($"aspire publish --output-path ../charts");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10));
// Step 19: Verify Helm chart was generated
output.WriteLine("Step 19: Verifying Helm chart generation...");
- sequenceBuilder
- .Type("ls -la ../charts && cat ../charts/Chart.yaml && cat ../charts/values.yaml")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("ls -la ../charts && cat ../charts/Chart.yaml && cat ../charts/values.yaml");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// ===== PHASE 3: Deploy to AKS and Verify =====
// Step 20: Verify ACR role assignment has propagated
output.WriteLine("Step 20: Verifying AKS can pull from ACR...");
- sequenceBuilder
- .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3));
+ await auto.TypeAsync($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
// Step 21: Deploy Helm chart to AKS with ACR image overrides
// Only project resources need image overrides — Redis uses the public image from the chart
@@ -309,97 +283,84 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can
// 2. secrets.webfrontend.cache_password: Cross-resource secret references create Helm value
// paths under the consuming resource instead of the owning resource (issue #14370).
output.WriteLine("Step 21: Deploying Helm chart to AKS...");
- sequenceBuilder
- .Type($"helm install aksredis ../charts --namespace default --wait --timeout 10m " +
- $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " +
- $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest " +
- $"--set secrets.cache.cache_password={redisPassword} " +
- $"--set secrets.webfrontend.cache_password={redisPassword}")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12));
+ await auto.TypeAsync($"helm install aksredis ../charts --namespace default --wait --timeout 10m " +
+ $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " +
+ $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest " +
+ $"--set secrets.cache.cache_password={redisPassword} " +
+ $"--set secrets.webfrontend.cache_password={redisPassword}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(12));
// Step 22: Wait for all pods to be ready (including Redis cache)
// Pods may need time to pull images from ACR and start the application
output.WriteLine("Step 22: Waiting for all pods to be ready...");
- sequenceBuilder
- .Type("kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=apiservice --timeout=300s -n default && " +
- "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=webfrontend --timeout=300s -n default && " +
- "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=cache --timeout=300s -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(16));
+ await auto.TypeAsync("kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=apiservice --timeout=300s -n default && " +
+ "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=webfrontend --timeout=300s -n default && " +
+ "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=cache --timeout=300s -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(16));
// Step 22b: Verify Redis container is running and stable (no restarts)
output.WriteLine("Step 22b: Verifying Redis container is stable...");
- sequenceBuilder
- .Type("kubectl get pod cache-statefulset-0 -o jsonpath='{.status.containerStatuses[0].ready} restarts:{.status.containerStatuses[0].restartCount}'")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get pod cache-statefulset-0 -o jsonpath='{.status.containerStatuses[0].ready} restarts:{.status.containerStatuses[0].restartCount}'");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 23: Verify all pods are running
output.WriteLine("Step 23: Verifying pods are running...");
- sequenceBuilder
- .Type("kubectl get pods -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get pods -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 24: Verify deployments are healthy
output.WriteLine("Step 24: Verifying deployments...");
- sequenceBuilder
- .Type("kubectl get deployments -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get deployments -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 25: Verify services (should include cache-service for Redis)
output.WriteLine("Step 25: Verifying services...");
- sequenceBuilder
- .Type("kubectl get services -n default")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync("kubectl get services -n default");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 26: Verify apiservice endpoint via port-forward
output.WriteLine("Step 26: Verifying apiservice /weatherforecast endpoint...");
- sequenceBuilder
- .Type("kubectl port-forward svc/apiservice-service 18080:8080 &")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10))
- .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("kubectl port-forward svc/apiservice-service 18080:8080 &");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 27: Verify webfrontend root page via port-forward
output.WriteLine("Step 27: Verifying webfrontend root page...");
- sequenceBuilder
- .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10))
- .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("kubectl port-forward svc/webfrontend-service 18081:8080 &");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 28: Verify webfrontend /weather page (exercises webfrontend → apiservice → Redis pipeline)
// The /weather page uses Blazor SSR streaming rendering which keeps the HTTP connection open.
// We use -m 5 (max-time) to avoid curl hanging, and capture the status code in a variable
// because --max-time causes curl to exit non-zero (code 28) even on HTTP 200.
output.WriteLine("Step 28: Verifying webfrontend /weather page (exercises Redis cache)...");
- sequenceBuilder
- .Type("for i in $(seq 1 10); do sleep 3; S=$(curl -so /dev/null -w '%{http_code}' -m 5 http://localhost:18081/weather); [ \"$S\" = \"200\" ] && echo \"$S OK\" && break; done")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120));
+ await auto.TypeAsync("for i in $(seq 1 10); do sleep 3; S=$(curl -so /dev/null -w '%{http_code}' -m 5 http://localhost:18081/weather); [ \"$S\" = \"200\" ] && echo \"$S OK\" && break; done");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
// Step 29: Clean up port-forwards
output.WriteLine("Step 29: Cleaning up port-forwards...");
- sequenceBuilder
- .Type("kill %1 %2 2>/dev/null; true")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10));
+ await auto.TypeAsync("kill %1 %2 2>/dev/null; true");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
// Step 30: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs
index c23ca35455d..1710ee2758f 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs
@@ -68,131 +68,113 @@ private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(Cancellation
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create Python FastAPI project using aspire new with interactive prompts
output.WriteLine("Step 3: Creating Python FastAPI project...");
- sequenceBuilder.AspireNew(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers)
output.WriteLine("Step 5: Adding Azure App Service hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppService");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify apphost.cs to add Azure App Service Environment
// Note: Python template uses single-file AppHost (apphost.cs in project root)
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- // Single-file AppHost is in the project root, not a subdirectory
- var appHostFilePath = Path.Combine(projectDir, "apphost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ // Single-file AppHost is in the project root, not a subdirectory
+ var appHostFilePath = Path.Combine(projectDir, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Insert the Azure App Service Environment before builder.Build().Run();
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ // Insert the Azure App Service Environment before builder.Build().Run();
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure App Service Environment for deployment
builder.AddAzureAppServiceEnvironment("infra");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified apphost.cs at: {appHostFilePath}");
// Step 7: Set environment for deployment
// - Unset ASPIRE_PLAYGROUND to avoid conflicts
// - Set Azure location to westus3 (same as other tests to use region with capacity)
// - Set AZURE__RESOURCEGROUP to use our unique resource group name
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: Deploy to Azure App Service using aspire deploy
output.WriteLine("Step 7: Starting Azure App Service deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- // Wait for pipeline to complete successfully (App Service can take longer)
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ // Wait for pipeline to complete successfully (App Service can take longer)
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 9: Extract deployment URLs and verify endpoints with retry
// For App Service, we use az webapp list instead of az containerapp list
// Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds)
output.WriteLine("Step 8: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
- "echo \"Resource group: $RG_NAME\" && " +
- "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
- // Get App Service hostnames (defaultHostName for each web app)
- "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " +
- "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " +
- "failed=0 && " +
- "for url in $urls; do " +
- "echo \"Checking https://$url...\"; " +
- "success=0; " +
- "for i in $(seq 1 18); do " +
- "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " +
- "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
- "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
- "done; " +
- "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
- "done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
+ "echo \"Resource group: $RG_NAME\" && " +
+ "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
+ // Get App Service hostnames (defaultHostName for each web app)
+ "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " +
+ "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " +
+ "failed=0 && " +
+ "for url in $urls; do " +
+ "echo \"Checking https://$url...\"; " +
+ "success=0; " +
+ "for i in $(seq 1 18); do " +
+ "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " +
+ "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
+ "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
+ "done; " +
+ "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
+ "done && " +
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 10: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs
index 3aa3cdde178..59ef9d2ac43 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs
@@ -68,20 +68,12 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
// The workflow builds and installs the CLI to ~/.aspire/bin before running tests
@@ -90,119 +82,108 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
// Source the CLI environment (sets PATH and other env vars)
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create React + ASP.NET Core project using aspire new with interactive prompts
output.WriteLine("Step 3: Creating React + ASP.NET Core project...");
- sequenceBuilder.AspireNew(projectName, counter, template: AspireTemplate.JsReact, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.JsReact, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers)
output.WriteLine("Step 5: Adding Azure App Service hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppService");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify AppHost.cs to add Azure App Service Environment
- sequenceBuilder.ExecuteCallback(() =>
- {
- var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
- var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
- var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
+ var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
+ var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
+ var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
- output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- // Insert the Azure App Service Environment before builder.Build().Run();
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ // Insert the Azure App Service Environment before builder.Build().Run();
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure App Service Environment for deployment
builder.AddAzureAppServiceEnvironment("infra");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
- });
+ output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
// Step 7: Navigate to AppHost project directory
output.WriteLine("Step 6: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: Set environment variables for deployment
// - Unset ASPIRE_PLAYGROUND to avoid conflicts
// - Set Azure location
// - Set AZURE__RESOURCEGROUP to use our unique resource group name
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Deploy to Azure App Service using aspire deploy
// Use --clear-cache to ensure fresh deployment without cached location from previous runs
output.WriteLine("Step 7: Starting Azure App Service deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- // Wait for pipeline to complete successfully (App Service can take longer)
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ // Wait for pipeline to complete successfully (App Service can take longer)
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 10: Extract deployment URLs and verify endpoints with retry
// For App Service, we use az webapp list instead of az containerapp list
// Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds)
output.WriteLine("Step 8: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
- "echo \"Resource group: $RG_NAME\" && " +
- "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
- // Get App Service hostnames (defaultHostName for each web app)
- "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " +
- "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " +
- "failed=0 && " +
- "for url in $urls; do " +
- "echo \"Checking https://$url...\"; " +
- "success=0; " +
- "for i in $(seq 1 18); do " +
- "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " +
- "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
- "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
- "done; " +
- "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
- "done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
+ "echo \"Resource group: $RG_NAME\" && " +
+ "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
+ // Get App Service hostnames (defaultHostName for each web app)
+ "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " +
+ "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " +
+ "failed=0 && " +
+ "for url in $urls; do " +
+ "echo \"Checking https://$url...\"; " +
+ "success=0; " +
+ "for i in $(seq 1 18); do " +
+ "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " +
+ "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " +
+ "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " +
+ "done; " +
+ "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
+ "done && " +
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 11: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj
index 9cbfc5f31e9..afcb5f41d69 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj
@@ -54,6 +54,7 @@
+
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs
index fd97e69de47..b11a437bab5 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs
@@ -63,85 +63,68 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- // Integration selection prompt appears when multiple packages match the search term
- var waitingForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- // Version selection prompt appears when selecting a package version in CI
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support)
// This command triggers TWO prompts in sequence:
// 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages)
// 2. Version selection prompt (in CI, to select package version)
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerApps");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
// First, handle integration selection prompt
- sequenceBuilder
- .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Select first integration (azure-appcontainers)
- // Then, handle version selection prompt
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version (PR build)
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first integration (azure-appcontainers)
+ // Then, handle version selection prompt
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.AppConfiguration package
// This command may only show version selection prompt (unique match)
output.WriteLine("Step 4b: Adding Azure App Configuration hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppConfiguration")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppConfiguration");
+ await auto.EnterAsync();
// In CI, aspire add shows version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure App Configuration resource
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container App Environment for managed identity support
_ = builder.AddAzureContainerAppEnvironment("env");
@@ -151,39 +134,33 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs to add Azure App Configuration resource");
- });
+ output.WriteLine($"Modified apphost.cs to add Azure App Configuration resource");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure App Configuration store was created
output.WriteLine("Step 8: Verifying Azure App Configuration store...");
- sequenceBuilder
- .Type($"az appconfig list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az appconfig list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs
index 0768af83d3e..6040eab28f5 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs
@@ -63,96 +63,79 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4: Add Aspire.Hosting.Azure.ContainerRegistry package
output.WriteLine("Step 4: Adding Azure Container Registry hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerRegistry");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Container Registry resource
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container Registry resource for deployment testing
builder.AddAzureContainerRegistry("acr");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs to add Azure Container Registry resource");
- });
+ output.WriteLine($"Modified apphost.cs to add Azure Container Registry resource");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Container Registry was created
output.WriteLine("Step 8: Verifying Azure Container Registry...");
- sequenceBuilder
- .Type($"az acr list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az acr list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs
index 3da83c3ca5e..edb1a26d1f2 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs
@@ -63,85 +63,68 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- // Integration selection prompt appears when multiple packages match the search term
- var waitingForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- // Version selection prompt appears when selecting a package version in CI
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support)
// This command triggers TWO prompts in sequence:
// 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages)
// 2. Version selection prompt (in CI, to select package version)
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerApps");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
// First, handle integration selection prompt
- sequenceBuilder
- .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Select first integration (azure-appcontainers)
- // Then, handle version selection prompt
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version (PR build)
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first integration (azure-appcontainers)
+ // Then, handle version selection prompt
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.EventHubs package
// This command may only show version selection prompt (unique match)
output.WriteLine("Step 4b: Adding Azure Event Hubs hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.EventHubs")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.EventHubs");
+ await auto.EnterAsync();
// In CI, aspire add shows version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Event Hubs resource
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container App Environment for managed identity support
_ = builder.AddAzureContainerAppEnvironment("env");
@@ -151,39 +134,33 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs to add Azure Event Hubs resource");
- });
+ output.WriteLine($"Modified apphost.cs to add Azure Event Hubs resource");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Event Hubs namespace was created
output.WriteLine("Step 8: Verifying Azure Event Hubs namespace...");
- sequenceBuilder
- .Type($"az eventhubs namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az eventhubs namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs
index ab868d6b2b2..8a8463e6283 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs
@@ -63,85 +63,68 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- // Integration selection prompt appears when multiple packages match the search term
- var waitingForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- // Version selection prompt appears when selecting a package version in CI
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support)
// This command triggers TWO prompts in sequence:
// 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages)
// 2. Version selection prompt (in CI, to select package version)
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerApps");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
// First, handle integration selection prompt
- sequenceBuilder
- .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Select first integration (azure-appcontainers)
- // Then, handle version selection prompt
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version (PR build)
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first integration (azure-appcontainers)
+ // Then, handle version selection prompt
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.KeyVault package
// This command may only show version selection prompt (unique match)
output.WriteLine("Step 4b: Adding Azure Key Vault hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.KeyVault");
+ await auto.EnterAsync();
// In CI, aspire add shows version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Key Vault resource
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Container App Environment for managed identity support
_ = builder.AddAzureContainerAppEnvironment("env");
@@ -151,39 +134,33 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs to add Azure Key Vault resource");
- });
+ output.WriteLine($"Modified apphost.cs to add Azure Key Vault resource");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Key Vault was created
output.WriteLine("Step 8: Verifying Azure Key Vault...");
- sequenceBuilder
- .Type($"az keyvault list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az keyvault list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs
index 7255b54f6ce..627db245ced 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs
@@ -63,96 +63,79 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4: Add Aspire.Hosting.Azure.OperationalInsights package
output.WriteLine("Step 4: Adding Azure Log Analytics hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.OperationalInsights")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.OperationalInsights");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Log Analytics Workspace resource
- sequenceBuilder.ExecuteCallback(() =>
- {
- var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
+ var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
- output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
+ output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
- var content = File.ReadAllText(appHostFilePath);
+ var content = File.ReadAllText(appHostFilePath);
- var buildRunPattern = "builder.Build().Run();";
- var replacement = """
+ var buildRunPattern = "builder.Build().Run();";
+ var replacement = """
// Add Azure Log Analytics Workspace resource for deployment testing
builder.AddAzureLogAnalyticsWorkspace("logs");
builder.Build().Run();
""";
- content = content.Replace(buildRunPattern, replacement);
- File.WriteAllText(appHostFilePath, content);
+ content = content.Replace(buildRunPattern, replacement);
+ File.WriteAllText(appHostFilePath, content);
- output.WriteLine($"Modified apphost.cs to add Azure Log Analytics Workspace resource");
- });
+ output.WriteLine($"Modified apphost.cs to add Azure Log Analytics Workspace resource");
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Log Analytics Workspace was created
output.WriteLine("Step 8: Verifying Azure Log Analytics Workspace...");
- sequenceBuilder
- .Type($"az monitor log-analytics workspace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az monitor log-analytics workspace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs
index b5a1f86ebc5..60b0bd508f8 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs
@@ -63,76 +63,60 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- // Integration selection prompt appears when multiple packages match the search term
- var waitingForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- // Version selection prompt appears when selecting a package version in CI
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support)
// This command triggers TWO prompts in sequence:
// 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages)
// 2. Version selection prompt (in CI, to select package version)
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerApps");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
// First, handle integration selection prompt
- sequenceBuilder
- .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Select first integration (azure-appcontainers)
- // Then, handle version selection prompt
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version (PR build)
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first integration (azure-appcontainers)
+ // Then, handle version selection prompt
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.ServiceBus package
// This command may only show version selection prompt (unique match)
output.WriteLine("Step 4b: Adding Azure Service Bus hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ServiceBus")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ServiceBus");
+ await auto.EnterAsync();
// In CI, aspire add shows version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Service Bus resource
- sequenceBuilder.ExecuteCallback(() =>
{
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
@@ -155,35 +139,30 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified apphost.cs to add Azure Service Bus resource");
- });
+ }
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Service Bus namespace was created
output.WriteLine("Step 8: Verifying Azure Service Bus namespace...");
- sequenceBuilder
- .Type($"az servicebus namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az servicebus namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs
index 274533792d6..3e5058dca56 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs
@@ -63,76 +63,60 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- // Integration selection prompt appears when multiple packages match the search term
- var waitingForIntegrationSelectionPrompt = new CellPatternSearcher()
- .Find("Select an integration to add:");
-
- // Version selection prompt appears when selecting a package version in CI
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support)
// This command triggers TWO prompts in sequence:
// 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages)
// 2. Version selection prompt (in CI, to select package version)
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ContainerApps");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
// First, handle integration selection prompt
- sequenceBuilder
- .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter() // Select first integration (azure-appcontainers)
- // Then, handle version selection prompt
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version (PR build)
+ await auto.WaitUntilTextAsync("Select an integration to add:", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first integration (azure-appcontainers)
+ // Then, handle version selection prompt
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.Storage package
// This command may only show version selection prompt (unique match)
output.WriteLine("Step 4b: Adding Azure Storage hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Storage");
+ await auto.EnterAsync();
// In CI, aspire add shows version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // Select first version
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // Select first version
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add Azure Storage resource
- sequenceBuilder.ExecuteCallback(() =>
{
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
@@ -157,36 +141,31 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation
output.WriteLine($"Modified apphost.cs to add Azure Storage resource");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure using aspire deploy
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- // Wait for pipeline to complete successfully
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ // Wait for pipeline to complete successfully
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(20));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify the Azure Storage account was created
output.WriteLine("Step 8: Verifying Azure Storage account...");
- sequenceBuilder
- .Type($"az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30));
+ await auto.TypeAsync($"az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs
new file mode 100644
index 00000000000..a06a0f8d317
--- /dev/null
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs
@@ -0,0 +1,114 @@
+// 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.Deployment.EndToEnd.Tests.Helpers;
+
+///
+/// Extension methods for providing deployment E2E test patterns.
+/// These parallel the -based methods in .
+///
+internal static class DeploymentE2EAutomatorHelpers
+{
+ ///
+ /// Prepares the terminal environment with a custom prompt for command tracking.
+ ///
+ internal static async Task PrepareEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ TemporaryWorkspace workspace,
+ SequenceCounter counter)
+ {
+ var waitingForInputPattern = new CellPatternSearcher()
+ .Find("b").RightUntil("$").Right(' ').Right(' ');
+
+ await auto.WaitUntilAsync(
+ s => waitingForInputPattern.Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "initial bash prompt");
+ await auto.WaitAsync(500);
+
+ // Bash prompt setup with command tracking
+ const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'";
+ await auto.TypeAsync(promptSetup);
+ await auto.EnterAsync();
+
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Installs the Aspire CLI from PR build artifacts.
+ ///
+ internal static async Task InstallAspireCliFromPullRequestAsync(
+ this Hex1bTerminalAutomator auto,
+ int prNumber,
+ SequenceCounter counter)
+ {
+ var command = $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
+
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
+ }
+
+ ///
+ /// Installs the latest GA (release quality) Aspire CLI.
+ ///
+ internal static async Task InstallAspireCliReleaseAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ var command = "curl -fsSL https://aka.ms/aspire/get/install.sh | bash -s -- --quality release";
+
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
+ }
+
+ ///
+ /// Configures the PATH and environment variables for the Aspire CLI.
+ ///
+ internal static async Task SourceAspireCliEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH ASPIRE_PLAYGROUND=true DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+
+ ///
+ /// Installs the Aspire CLI Bundle from a specific pull request's artifacts.
+ /// The bundle includes the native AOT CLI, .NET runtime, Dashboard, DCP, and AppHost Server.
+ ///
+ internal static async Task InstallAspireBundleFromPullRequestAsync(
+ this Hex1bTerminalAutomator auto,
+ int prNumber,
+ SequenceCounter counter)
+ {
+ var command = $"ref=$(gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha') && " +
+ $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
+
+ await auto.TypeAsync(command);
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
+ }
+
+ ///
+ /// Sources the Aspire Bundle environment after installation.
+ /// Adds both the bundle's bin/ and root directories to PATH.
+ ///
+ internal static async Task SourceAspireBundleEnvironmentAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+ }
+}
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs
index 6d713ec4b7a..870da4f9651 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs
@@ -68,57 +68,46 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create Python FastAPI project using aspire new with interactive prompts
output.WriteLine("Step 3: Creating Python FastAPI project...");
- sequenceBuilder.AspireNew(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.PythonReact, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify apphost.cs to add Azure Container App Environment
// Note: Python template uses single-file AppHost (apphost.cs in project root)
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
// Single-file AppHost is in the project root, not a subdirectory
@@ -141,30 +130,28 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified apphost.cs at: {appHostFilePath}");
- });
+ }
// Step 7: Set environment for deployment
// - Unset ASPIRE_PLAYGROUND to avoid conflicts
// - Set Azure location to westus3 (same as other tests to use region with capacity)
// - Set AZURE__RESOURCEGROUP to use our unique resource group name
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 9: Deploy to Azure Container Apps using aspire deploy
output.WriteLine("Step 7: Starting Azure Container Apps deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- // Wait for pipeline to complete successfully
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ // Wait for pipeline to complete successfully
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 10: Extract deployment URLs and verify endpoints with retry
// Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds)
output.WriteLine("Step 8: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
"echo \"Resource group: $RG_NAME\" && " +
"if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
// Get external endpoints only (exclude .internal. which are not publicly accessible)
@@ -181,17 +168,14 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 11: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs
index 039df3df77c..1c9cf2a26ba 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs
@@ -65,20 +65,12 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- // Pattern searchers for aspire add prompts
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- // Pattern searcher for deployment success
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
// TypeScript apphosts need the full bundle (not just the CLI binary) because
@@ -89,39 +81,36 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance
if (prNumber > 0)
{
output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}...");
- sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter);
+ await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter);
}
- sequenceBuilder.SourceAspireBundleEnvironment(counter);
+ await auto.SourceAspireBundleEnvironmentAsync(counter);
}
// Step 3: Create TypeScript Express/React project using aspire new
output.WriteLine("Step 3: Creating TypeScript Express/React project...");
- sequenceBuilder.AspireNew(projectName, counter, template: AspireTemplate.ExpressReact);
+ await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.ExpressReact);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
// In CI, aspire add shows a version selection prompt
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter(); // select first version (PR build)
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync(); // select first version (PR build)
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Modify apphost.ts to add Azure Container App Environment for deployment
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var appHostFilePath = Path.Combine(projectDir, "apphost.ts");
@@ -144,25 +133,23 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance
File.WriteAllText(appHostFilePath, content);
output.WriteLine($"Modified apphost.ts at: {appHostFilePath}");
- });
+ }
// Step 7: Set environment for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 8: Deploy to Azure Container Apps using aspire deploy
output.WriteLine("Step 8: Starting Azure Container Apps deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 9: Extract deployment URLs and verify endpoints with retry
output.WriteLine("Step 9: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
"echo \"Resource group: $RG_NAME\" && " +
"if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " +
"urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
@@ -178,17 +165,14 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 10: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs
index 699d3f414b8..01bfb7aabf8 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs
@@ -64,86 +64,74 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create starter project using aspire new
output.WriteLine("Step 3: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 4: Navigate to project directory
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5a: Add Aspire.Hosting.Azure.AppContainers
output.WriteLine("Step 5a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5b: Add Aspire.Hosting.Azure.Network
output.WriteLine("Step 5b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5c: Add Aspire.Hosting.Azure.KeyVault
output.WriteLine("Step 5c: Adding Azure Key Vault hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.KeyVault");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Add Aspire client package to the Web project
output.WriteLine("Step 6: Adding Key Vault client package to Web project...");
- sequenceBuilder
- .Type($"dotnet add {projectName}.Web package Aspire.Azure.Security.KeyVault --prerelease")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120));
+ await auto.TypeAsync($"dotnet add {projectName}.Web package Aspire.Azure.Security.KeyVault --prerelease");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
// Step 7: Modify AppHost.cs to add VNet + PE + WithReference
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
@@ -183,10 +171,9 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella
output.WriteLine($"Modified AppHost.cs with VNet + Key Vault PE + WithReference");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 8: Modify Web project Program.cs to register Key Vault client
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs");
@@ -205,39 +192,35 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella
File.WriteAllText(webProgramPath, content);
output.WriteLine($"Modified Web Program.cs to add Key Vault client registration");
- });
+ }
// Step 9: Navigate to AppHost project directory
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 11: Deploy to Azure
output.WriteLine("Step 11: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 12: Verify PE infrastructure
output.WriteLine("Step 12: Verifying PE infrastructure...");
- sequenceBuilder
- .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
+ $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 13: Verify deployed endpoints with retry
output.WriteLine("Step 13: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
"urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
"if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " +
"failed=0 && " +
@@ -251,17 +234,14 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 14: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs
index 9fe92fa31c0..5b9815d13bb 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs
@@ -61,73 +61,63 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.AppContainers
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.Network
output.WriteLine("Step 4b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4c: Add Aspire.Hosting.Azure.KeyVault
output.WriteLine("Step 4c: Adding Azure Key Vault hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.KeyVault");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add VNet + PE infrastructure
- sequenceBuilder.ExecuteCallback(() =>
{
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
@@ -160,37 +150,32 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel
output.WriteLine($"Modified apphost.cs with VNet + Key Vault PE infrastructure");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(25));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify VNet infrastructure
output.WriteLine("Step 8: Verifying VNet infrastructure...");
- sequenceBuilder
- .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
+ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
$"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs
index 0e4f199dffa..5d176d2b0a3 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs
@@ -64,86 +64,74 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create starter project using aspire new
output.WriteLine("Step 3: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 4: Navigate to project directory
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5a: Add Aspire.Hosting.Azure.AppContainers
output.WriteLine("Step 5a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5b: Add Aspire.Hosting.Azure.Network
output.WriteLine("Step 5b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5c: Add Aspire.Hosting.Azure.Sql
output.WriteLine("Step 5c: Adding Azure SQL hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Sql")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Sql");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Add Aspire client package to the Web project
output.WriteLine("Step 6: Adding SQL client package to Web project...");
- sequenceBuilder
- .Type($"dotnet add {projectName}.Web package Aspire.Microsoft.Data.SqlClient --prerelease")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120));
+ await auto.TypeAsync($"dotnet add {projectName}.Web package Aspire.Microsoft.Data.SqlClient --prerelease");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
// Step 7: Modify AppHost.cs to add VNet + PE + WithReference
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
@@ -184,10 +172,9 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell
output.WriteLine($"Modified AppHost.cs with VNet + SQL Server PE + WithReference");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 8: Modify Web project Program.cs to register SQL client
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs");
@@ -206,39 +193,35 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell
File.WriteAllText(webProgramPath, content);
output.WriteLine($"Modified Web Program.cs to add SQL client registration");
- });
+ }
// Step 9: Navigate to AppHost project directory
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 11: Deploy to Azure
output.WriteLine("Step 11: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 12: Verify PE infrastructure
output.WriteLine("Step 12: Verifying PE infrastructure...");
- sequenceBuilder
- .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
+ $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 13: Verify deployed endpoints with retry
output.WriteLine("Step 13: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
"urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
"if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " +
"failed=0 && " +
@@ -252,17 +235,14 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 14: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs
index f85d99b5d76..826ffbf4bda 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs
@@ -61,73 +61,63 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.AppContainers
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.Network
output.WriteLine("Step 4b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4c: Add Aspire.Hosting.Azure.Sql
output.WriteLine("Step 4c: Adding Azure SQL hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Sql")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Sql");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add VNet + PE infrastructure
- sequenceBuilder.ExecuteCallback(() =>
{
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
@@ -161,37 +151,32 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance
output.WriteLine($"Modified apphost.cs with VNet + SQL Server PE infrastructure");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(25));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify VNet infrastructure
output.WriteLine("Step 8: Verifying VNet infrastructure...");
- sequenceBuilder
- .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
+ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
$"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs
index d00fd1ee7b6..8458572d48e 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs
@@ -64,88 +64,76 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForAddVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create starter project using aspire new
output.WriteLine("Step 3: Creating starter project...");
- sequenceBuilder.AspireNew(projectName, counter, useRedisCache: false);
+ await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
// Step 4: Navigate to project directory
output.WriteLine("Step 4: Navigating to project directory...");
- sequenceBuilder
- .Type($"cd {projectName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 5a: Add Aspire.Hosting.Azure.AppContainers package
output.WriteLine("Step 5a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5b: Add Aspire.Hosting.Azure.Network package
output.WriteLine("Step 5b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5c: Add Aspire.Hosting.Azure.Storage package
output.WriteLine("Step 5c: Adding Azure Storage hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Storage");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 6: Add Aspire client package to the Web project
output.WriteLine("Step 6: Adding blob client package to Web project...");
- sequenceBuilder
- .Type($"dotnet add {projectName}.Web package Aspire.Azure.Storage.Blobs --prerelease")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120));
+ await auto.TypeAsync($"dotnet add {projectName}.Web package Aspire.Azure.Storage.Blobs --prerelease");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
// Step 7: Modify AppHost.cs to add VNet + PE + WithReference
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
@@ -188,10 +176,9 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance
output.WriteLine($"Modified AppHost.cs with VNet + Storage PE + WithReference");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 8: Modify Web project Program.cs to register blob client
- sequenceBuilder.ExecuteCallback(() =>
{
var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs");
@@ -211,40 +198,36 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance
File.WriteAllText(webProgramPath, content);
output.WriteLine($"Modified Web Program.cs to add blob client registration");
- });
+ }
// Step 9: Navigate to AppHost project directory
output.WriteLine("Step 9: Navigating to AppHost directory...");
- sequenceBuilder
- .Type($"cd {projectName}.AppHost")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"cd {projectName}.AppHost");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 10: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 11: Deploy to Azure
output.WriteLine("Step 11: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 12: Verify PE infrastructure
output.WriteLine("Step 12: Verifying PE infrastructure...");
- sequenceBuilder
- .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
+ $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 13: Verify deployed endpoints with retry
output.WriteLine("Step 13: Verifying deployed endpoints...");
- sequenceBuilder
- .Type($"RG_NAME=\"{resourceGroupName}\" && " +
+ await auto.TypeAsync($"RG_NAME=\"{resourceGroupName}\" && " +
"urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " +
"if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " +
"failed=0 && " +
@@ -258,17 +241,14 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance
"done; " +
"if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " +
"done && " +
- "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5));
+ "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
// Step 14: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs
index c1f1fe63c15..c442f9a1c11 100644
--- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs
@@ -61,74 +61,64 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can
using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(cancellationToken);
- var waitingForVersionSelectionPrompt = new CellPatternSearcher()
- .Find("(based on NuGet.config)");
-
- var waitingForPipelineSucceeded = new CellPatternSearcher()
- .Find("PIPELINE SUCCEEDED");
-
var counter = new SequenceCounter();
- var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
// Step 1: Prepare environment
output.WriteLine("Step 1: Preparing environment...");
- sequenceBuilder.PrepareEnvironment(workspace, counter);
+ await auto.PrepareEnvironmentAsync(workspace, counter);
// Step 2: Set up CLI environment (in CI)
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build...");
- sequenceBuilder.SourceAspireCliEnvironment(counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
}
// Step 3: Create single-file AppHost using aspire init
output.WriteLine("Step 3: Creating single-file AppHost with aspire init...");
- sequenceBuilder.AspireInit(counter);
+ await auto.AspireInitAsync(counter);
// Step 4a: Add Aspire.Hosting.Azure.AppContainers
output.WriteLine("Step 4a: Adding Azure Container Apps hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4b: Add Aspire.Hosting.Azure.Network
output.WriteLine("Step 4b: Adding Azure Network hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 4c: Add Aspire.Hosting.Azure.Storage
output.WriteLine("Step 4c: Adding Azure Storage hosting package...");
- sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage")
- .Enter();
+ await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Storage");
+ await auto.EnterAsync();
if (DeploymentE2ETestHelpers.IsRunningInCI)
{
- sequenceBuilder
- .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60))
- .Enter();
+ await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
+ await auto.EnterAsync();
}
- sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
// Step 5: Modify apphost.cs to add VNet + PE infrastructure
- sequenceBuilder.ExecuteCallback(() =>
{
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}");
@@ -162,37 +152,32 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can
output.WriteLine($"Modified apphost.cs with VNet + Storage PE infrastructure");
output.WriteLine($"New content:\n{content}");
- });
+ }
// Step 6: Set environment variables for deployment
- sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}")
- .Enter()
- .WaitForSuccessPrompt(counter);
+ await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
// Step 7: Deploy to Azure
output.WriteLine("Step 7: Starting Azure deployment...");
- sequenceBuilder
- .Type("aspire deploy --clear-cache")
- .Enter()
- .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25))
- .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2));
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(25));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
// Step 8: Verify VNet infrastructure
output.WriteLine("Step 8: Verifying VNet infrastructure...");
- sequenceBuilder
- .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
+ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " +
$"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " +
- $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv")
- .Enter()
- .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60));
+ $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
// Step 9: Exit terminal
- sequenceBuilder
- .Type("exit")
- .Enter();
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
- var sequence = sequenceBuilder.Build();
- await sequence.ApplyAsync(terminal, cancellationToken);
await pendingRun;
var duration = DateTime.UtcNow - startTime;
diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs
index b9bd03c3da0..5cc9e7b377b 100644
--- a/tests/Shared/Hex1bAutomatorTestHelpers.cs
+++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs
@@ -303,4 +303,37 @@ await auto.WaitUntilAsync(
// Step 8: Decline the agent init prompt and wait for success
await auto.DeclineAgentInitPromptAsync(counter);
}
+
+ ///
+ /// Runs aspire init --language csharp and handles the NuGet.config and agent init prompts.
+ ///
+ internal static async Task AspireInitAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter)
+ {
+ var waitingForNuGetConfigPrompt = new CellPatternSearcher()
+ .Find("NuGet.config");
+
+ var waitingForInitComplete = new CellPatternSearcher()
+ .Find("Aspire initialization complete");
+
+ await auto.TypeAsync("aspire init --language csharp");
+ await auto.EnterAsync();
+
+ // NuGet.config prompt may or may not appear depending on environment.
+ // Wait for either the NuGet.config prompt or init completion.
+ await auto.WaitUntilAsync(
+ s => waitingForNuGetConfigPrompt.Search(s).Count > 0
+ || waitingForInitComplete.Search(s).Count > 0,
+ timeout: TimeSpan.FromMinutes(2),
+ description: "NuGet.config prompt or init completion");
+ await auto.EnterAsync(); // Dismiss NuGet.config prompt if present
+
+ await auto.WaitUntilAsync(
+ s => waitingForInitComplete.Search(s).Count > 0,
+ timeout: TimeSpan.FromMinutes(2),
+ description: "aspire initialization complete");
+
+ await auto.DeclineAgentInitPromptAsync(counter);
+ }
}