Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs
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;
Copy link

Copilot AI Mar 20, 2026

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 failing aspire update runs pass silently and lead to confusing downstream failures. Consider tracking whether an ERR prompt was observed (similar to WaitForSuccessPromptFailFastAsync) and throwing an exception when it occurs (ideally including the sequence number / guidance to check the terminal recording).

Suggested change
return true;
throw new InvalidOperationException(
$"aspire update reported an error at sequence [{expectedCounter}]. " +
"Check the terminal recording for details.");

Copilot uses AI. Check for mistakes.
}

// 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);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TemporaryWorkspace implements IDisposable and deletes its temp directory on disposal. This test creates a workspace but never disposes it, which can leak temp directories over repeated runs (local dev and CI). Consider changing this to using var workspace = TemporaryWorkspace.Create(output); so cleanup happens after the Docker terminal is disposed.

Copilot uses AI. Check for mistakes.

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;
}
}
Loading