-
Notifications
You must be signed in to change notification settings - Fork 846
[cli] Add E2E sample upgrade tests for aspire-samples #15421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
70a8328
e6f2cac
6bd67c7
990f2a1
07ca651
c6dc51b
24b7300
e70bceb
100ba96
9ddc8a9
0bc5b32
995e6bc
7fe1be6
dc2a7b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using Hex1b.Automation; | ||
| using Hex1b.Input; | ||
|
|
||
| namespace Aspire.Cli.EndToEnd.Tests.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for <see cref="Hex1bTerminalAutomator"/> providing helpers for | ||
| /// sample upgrade E2E tests. These tests clone external repos (e.g., dotnet/aspire-samples), | ||
| /// run <c>aspire update</c> to upgrade them to the PR/CI build, and then run the apphost | ||
| /// to verify the sample works correctly. | ||
| /// </summary> | ||
| internal static class SampleUpgradeHelpers | ||
| { | ||
| private const string DefaultSamplesRepoUrl = "https://github.com/dotnet/aspire-samples.git"; | ||
| private const string DefaultSamplesBranch = "main"; | ||
|
|
||
| /// <summary> | ||
| /// Clones a Git repository inside the container. | ||
| /// </summary> | ||
| /// <param name="auto">The terminal automator.</param> | ||
| /// <param name="counter">The sequence counter for prompt tracking.</param> | ||
| /// <param name="repoUrl">The Git repository URL. Defaults to dotnet/aspire-samples.</param> | ||
| /// <param name="branch">The branch to clone. Defaults to <c>main</c>.</param> | ||
| /// <param name="depth">The clone depth. Defaults to 1 for shallow clone.</param> | ||
| /// <param name="timeout">Timeout for the clone operation. Defaults to 120 seconds.</param> | ||
| internal static async Task CloneSampleRepoAsync( | ||
| this Hex1bTerminalAutomator auto, | ||
| SequenceCounter counter, | ||
| string repoUrl = DefaultSamplesRepoUrl, | ||
| string branch = DefaultSamplesBranch, | ||
| int depth = 1, | ||
| TimeSpan? timeout = null) | ||
| { | ||
| var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(120); | ||
|
|
||
| await auto.TypeAsync($"git clone --depth {depth} --single-branch --branch {branch} {repoUrl}"); | ||
| await auto.EnterAsync(); | ||
| await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Runs <c>aspire update</c> on a cloned sample, handling interactive prompts. | ||
| /// Navigates to the sample directory, runs the update, and handles channel selection | ||
| /// and CLI update prompts. | ||
| /// </summary> | ||
| /// <param name="auto">The terminal automator.</param> | ||
| /// <param name="counter">The sequence counter for prompt tracking.</param> | ||
| /// <param name="samplePath">The relative path to the sample directory from the current working directory (e.g., <c>aspire-samples/samples/aspire-with-node</c>).</param> | ||
| /// <param name="timeout">Timeout for the update operation. Defaults to 180 seconds.</param> | ||
| internal static async Task AspireUpdateInSampleAsync( | ||
| this Hex1bTerminalAutomator auto, | ||
| SequenceCounter counter, | ||
| string samplePath, | ||
| TimeSpan? timeout = null) | ||
| { | ||
| var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(180); | ||
|
|
||
| // Navigate to the sample directory | ||
| await auto.TypeAsync($"cd {samplePath}"); | ||
| await auto.EnterAsync(); | ||
| await auto.WaitForSuccessPromptAsync(counter); | ||
|
|
||
| // Run aspire update. The behavior depends on the install mode: | ||
| // - PR mode (hives exist): May prompt for channel selection ("Select a channel:") | ||
| // - GA/source mode (no hives): Auto-selects the implicit/default channel | ||
| // After update, may prompt to update CLI ("Would you like to update it now?") | ||
| await auto.TypeAsync("aspire update"); | ||
| await auto.EnterAsync(); | ||
|
|
||
| // Wait for completion. Handle interactive prompts along the way: | ||
| // 1. Channel selection prompt (if hives exist) — select default (Enter) | ||
| // 2. CLI update prompt (after package update) — decline (type 'n') | ||
| var expectedCounter = counter.Value; | ||
| var channelPromptHandled = false; | ||
| var cliUpdatePromptHandled = false; | ||
|
|
||
| await auto.WaitUntilAsync(snapshot => | ||
| { | ||
| // Check if the command completed (success or error) | ||
| var successSearcher = new CellPatternSearcher() | ||
| .FindPattern(expectedCounter.ToString()) | ||
| .RightText(" OK] $ "); | ||
| if (successSearcher.Search(snapshot).Count > 0) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| var errorSearcher = new CellPatternSearcher() | ||
| .FindPattern(expectedCounter.ToString()) | ||
| .RightText(" ERR:"); | ||
| if (errorSearcher.Search(snapshot).Count > 0) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| // Handle "Select a channel:" prompt — select the first option (Enter) | ||
| if (!channelPromptHandled && snapshot.ContainsText("Select a channel:")) | ||
| { | ||
| channelPromptHandled = true; | ||
| _ = Task.Run(async () => | ||
| { | ||
| await auto.WaitAsync(500); | ||
| await auto.EnterAsync(); | ||
| }); | ||
| } | ||
|
|
||
| // Handle "Would you like to update it now?" CLI update prompt — decline | ||
| if (!cliUpdatePromptHandled && snapshot.ContainsText("Would you like to update it now?")) | ||
| { | ||
| cliUpdatePromptHandled = true; | ||
| _ = Task.Run(async () => | ||
| { | ||
| await auto.WaitAsync(500); | ||
| await auto.TypeAsync("n"); | ||
| await auto.EnterAsync(); | ||
| }); | ||
| } | ||
|
|
||
| return false; | ||
| }, timeout: effectiveTimeout, description: "aspire update to complete"); | ||
|
|
||
| counter.Increment(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Runs <c>aspire run</c> on a sample and waits for the apphost to start. | ||
| /// Returns when the "Press CTRL+C to stop the apphost and exit." message is displayed. | ||
| /// </summary> | ||
| /// <param name="auto">The terminal automator.</param> | ||
| /// <param name="appHostRelativePath">Optional relative path to the AppHost csproj file. If specified, passed as <c>--apphost</c>.</param> | ||
| /// <param name="startTimeout">Timeout for the apphost to start. Defaults to 5 minutes.</param> | ||
| internal static async Task AspireRunSampleAsync( | ||
| this Hex1bTerminalAutomator auto, | ||
| string? appHostRelativePath = null, | ||
| TimeSpan? startTimeout = null) | ||
| { | ||
| var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(5); | ||
|
|
||
| var command = appHostRelativePath is not null | ||
| ? $"aspire run --apphost {appHostRelativePath}" | ||
| : "aspire run"; | ||
|
|
||
| await auto.TypeAsync(command); | ||
| await auto.EnterAsync(); | ||
|
|
||
| // Wait for the apphost to start successfully | ||
| await auto.WaitUntilAsync(s => | ||
| { | ||
| // Fail fast if apphost selection prompt appears (multiple apphosts detected) | ||
| if (s.ContainsText("Select an apphost to use:")) | ||
| { | ||
| throw new InvalidOperationException( | ||
| "Unexpected apphost selection prompt detected! " + | ||
| "This indicates multiple apphosts were incorrectly detected in the sample."); | ||
| } | ||
|
|
||
| return s.ContainsText("Press CTRL+C to stop the apphost and exit."); | ||
| }, timeout: effectiveTimeout, description: "aspire run to start (Press CTRL+C message)"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Stops a running <c>aspire run</c> instance by sending Ctrl+C. | ||
| /// </summary> | ||
| /// <param name="auto">The terminal automator.</param> | ||
| /// <param name="counter">The sequence counter for prompt tracking.</param> | ||
| /// <param name="timeout">Timeout for the stop operation. Defaults to 60 seconds.</param> | ||
| internal static async Task StopAspireRunAsync( | ||
| this Hex1bTerminalAutomator auto, | ||
| SequenceCounter counter, | ||
| TimeSpan? timeout = null) | ||
| { | ||
| var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); | ||
|
|
||
| await auto.Ctrl().KeyAsync(Hex1bKey.C); | ||
| await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Verifies an HTTP endpoint is reachable from inside the container using <c>curl</c>. | ||
| /// </summary> | ||
| /// <param name="auto">The terminal automator.</param> | ||
| /// <param name="counter">The sequence counter for prompt tracking.</param> | ||
| /// <param name="url">The URL to check.</param> | ||
| /// <param name="expectedStatusCode">The expected HTTP status code. Defaults to 200.</param> | ||
| /// <param name="timeout">Timeout for the HTTP request. Defaults to 30 seconds.</param> | ||
| internal static async Task VerifyHttpEndpointAsync( | ||
| this Hex1bTerminalAutomator auto, | ||
| SequenceCounter counter, | ||
| string url, | ||
| int expectedStatusCode = 200, | ||
| TimeSpan? timeout = null) | ||
| { | ||
| var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); | ||
| var marker = $"endpoint-http-{expectedStatusCode}"; | ||
|
|
||
| await auto.TypeAsync( | ||
| $"curl -ksSL -o /dev/null -w 'endpoint-http-%{{http_code}}' \"{url}\" " + | ||
| "|| echo 'endpoint-http-failed'"); | ||
| await auto.EnterAsync(); | ||
| await auto.WaitUntilTextAsync(marker, timeout: effectiveTimeout); | ||
| await auto.WaitForSuccessPromptAsync(counter); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using Aspire.Cli.EndToEnd.Tests.Helpers; | ||
| using Aspire.Cli.Tests.Utils; | ||
| using Hex1b.Automation; | ||
| using Xunit; | ||
|
|
||
| namespace Aspire.Cli.EndToEnd.Tests; | ||
|
|
||
| /// <summary> | ||
| /// E2E test that clones the aspire-with-node sample from dotnet/aspire-samples, | ||
| /// upgrades it to the PR/CI build using <c>aspire update</c>, and verifies it runs correctly. | ||
| /// The sample consists of a Node.js Express frontend, an ASP.NET Core weather API, and Redis. | ||
| /// </summary> | ||
| public sealed class SampleUpgradeAspireWithNodeTests(ITestOutputHelper output) | ||
| { | ||
| [Fact] | ||
| public async Task UpgradeAndRunAspireWithNodeSample() | ||
| { | ||
| var repoRoot = CliE2ETestHelpers.GetRepoRoot(); | ||
| var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); | ||
|
|
||
| var workspace = TemporaryWorkspace.Create(output); | ||
|
|
||
| using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( | ||
| repoRoot, installMode, output, | ||
| mountDockerSocket: true, | ||
| workspace: workspace); | ||
|
||
|
|
||
| var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); | ||
|
|
||
| var counter = new SequenceCounter(); | ||
| var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(600)); | ||
|
|
||
| // Prepare Docker environment (prompt counting, umask, env vars) | ||
| await auto.PrepareDockerEnvironmentAsync(counter, workspace); | ||
|
|
||
| // Install the Aspire CLI | ||
| await auto.InstallAspireCliInDockerAsync(installMode, counter); | ||
|
|
||
| // Clone the aspire-samples repository | ||
| await auto.CloneSampleRepoAsync(counter); | ||
|
|
||
| // Update the aspire-with-node sample to the PR/CI build | ||
| await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node"); | ||
|
|
||
| // Run the sample — the AppHost csproj is in the AspireWithNode.AppHost subdirectory | ||
| await auto.AspireRunSampleAsync( | ||
| appHostRelativePath: "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", | ||
| startTimeout: TimeSpan.FromMinutes(5)); | ||
|
|
||
| // Stop the running apphost | ||
| await auto.StopAspireRunAsync(counter); | ||
|
|
||
| // Exit the shell | ||
| await auto.TypeAsync("exit"); | ||
| await auto.EnterAsync(); | ||
|
|
||
| await pendingRun; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
AspireUpdateInSampleAsync, an error prompt ([N ERR:...]) causes the wait loop to return, but the method then increments the counter and continues as if the update succeeded. This can let failingaspire updateruns pass silently and lead to confusing downstream failures. Consider tracking whether an ERR prompt was observed (similar toWaitForSuccessPromptFailFastAsync) and throwing an exception when it occurs (ideally including the sequence number / guidance to check the terminal recording).