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); + } }