diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index f2ff329dd73..3c7ace4e4b9 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -139,7 +139,7 @@ ASPIRE_E2E_ARCHIVE=/tmp/aspire-e2e.tar.gz \ -- --filter-method "*.YourTestName" # 4. If it fails, check the asciinema recording -# Recordings are saved to $TMPDIR/aspire-cli-e2e/recordings/ +# Recordings are saved under the test output TestResults/recordings/ directory # Play with: asciinema play /path/to/YourTestName.cast # 5. Fix and repeat from step 1 or 2 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 042a2eb56e6..30aa9ee3ff6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -335,6 +335,7 @@ jobs: # PR metadata and token for CLI E2E tests that download artifacts from a PR GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }} GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (60s interval to reduce log noise) @@ -378,6 +379,7 @@ jobs: # PR metadata and token for CLI E2E tests that download artifacts from a PR GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }} GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) @@ -427,6 +429,7 @@ jobs: # PR metadata and token for CLI E2E tests that download artifacts from a PR GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }} GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (60s interval to reduce log noise) @@ -477,6 +480,7 @@ jobs: # PR metadata and token for CLI E2E tests that download artifacts from a PR GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} + ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }} GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) diff --git a/localhive.sh b/localhive.sh index d79ad54a261..f262b90f426 100755 --- a/localhive.sh +++ b/localhive.sh @@ -278,10 +278,17 @@ if [[ $USE_COPY -eq 1 ]]; then log "Populating hive '$HIVE_NAME' by copying .nupkg files (version suffix: $VERSION_SUFFIX)" mkdir -p "$HIVE_PATH" # Only copy packages matching the current version suffix to avoid accumulating stale packages + copied_packages=0 + shopt -s nullglob for pkg in "$PKG_DIR"/*"$VERSION_SUFFIX"*.nupkg; do - [ -f "$pkg" ] && cp -f "$pkg" "$HIVE_PATH"/ + pkg_name="$(basename "$pkg")" + if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then + cp -f "$pkg" "$HIVE_PATH"/ + copied_packages=$((copied_packages + 1)) + fi done - log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)." + shopt -u nullglob + log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)." else log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR" mkdir -p "$HIVE_ROOT" @@ -290,8 +297,17 @@ else else warn "Symlink not supported; copying .nupkg files instead" mkdir -p "$HIVE_PATH" - cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true - log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)." + copied_packages=0 + shopt -s nullglob + for pkg in "$PKG_DIR"/*.nupkg; do + pkg_name="$(basename "$pkg")" + if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then + cp -f "$pkg" "$HIVE_PATH"/ + copied_packages=$((copied_packages + 1)) + fi + done + shopt -u nullglob + log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)." fi fi @@ -411,7 +427,7 @@ if [[ $ARCHIVE -eq 1 ]]; then else ARCHIVE_PATH="${ARCHIVE_BASE}.tar.gz" log "Creating archive: $ARCHIVE_PATH" - tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" . + COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" . fi log "Archive created: $ARCHIVE_PATH" fi diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index c28d6bfccee..9eb66f5517b 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -569,14 +569,25 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client); // NOTE: The change to @ over :: for template version separator (now enforced in .NET 10.0 SDK). - List cliArgs = ["new", "install", $"{packageName}@{version}"]; + var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory; + var localPackagePath = ResolveLocalTemplatePackagePath(packageName, version, nugetSource, workingDirectory); + + // dotnet new install .nupkg --force can register duplicate template packages for the same + // local file. Refresh local packages by uninstalling first, then reinstalling without --force. + if (localPackagePath is not null && force) + { + await UninstallTemplateAsync(packageName, workingDirectory, cancellationToken); + force = false; + } + + List cliArgs = ["new", "install", localPackagePath?.FullName ?? $"{packageName}@{version}"]; if (force) { cliArgs.Add("--force"); } - if (nugetSource is not null) + if (localPackagePath is null && nugetSource is not null) { cliArgs.Add("--nuget-source"); cliArgs.Add(nugetSource); @@ -602,7 +613,6 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, // folder as the working directory for the command. If we are using an implicit channel // then we just use the current execution context for the CLI and inherit whatever // NuGet.configs that may or may not be laying around. - var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory; var exitCode = await ExecuteAsync( args: [.. cliArgs], @@ -634,6 +644,11 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, } else { + if (localPackagePath is not null) + { + return (exitCode, version); + } + if (stdout is null) { logger.LogError("Failed to read stdout from the process. This should never happen."); @@ -659,6 +674,68 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, } } + private async Task UninstallTemplateAsync(string packageName, DirectoryInfo workingDirectory, CancellationToken cancellationToken) + { + var exitCode = await ExecuteAsync( + args: ["new", "uninstall", packageName], + env: new Dictionary + { + [KnownConfigNames.DotnetCliUiLanguage] = "en-US" + }, + projectFile: null, + workingDirectory: workingDirectory, + backchannelCompletionSource: null, + options: new ProcessInvocationOptions + { + SuppressLogging = true + }, + cancellationToken: cancellationToken); + + if (exitCode != 0) + { + logger.LogDebug("dotnet new uninstall {PackageName} returned {ExitCode} before local reinstall.", packageName, exitCode); + } + } + + private static FileInfo? ResolveLocalTemplatePackagePath(string packageName, string version, string? nugetSource, DirectoryInfo workingDirectory) + { + if (string.IsNullOrWhiteSpace(nugetSource)) + { + return null; + } + + string sourcePath; + if (Uri.TryCreate(nugetSource, UriKind.Absolute, out var uri)) + { + if (!uri.IsFile) + { + return null; + } + + sourcePath = uri.LocalPath; + } + else + { + sourcePath = Path.GetFullPath(nugetSource, workingDirectory.FullName); + } + + if (File.Exists(sourcePath) && string.Equals(Path.GetExtension(sourcePath), ".nupkg", StringComparison.OrdinalIgnoreCase)) + { + return new FileInfo(sourcePath); + } + + if (!Directory.Exists(sourcePath)) + { + return null; + } + + var expectedFileName = $"{packageName}.{version}.nupkg"; + var packagePath = Directory.EnumerateFiles(sourcePath, "*.nupkg", SearchOption.TopDirectoryOnly) + .FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgnoreCase)); + + return packagePath is null ? null : new FileInfo(packagePath); + } + internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version) { var lines = stdout.Split(Environment.NewLine); diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index cf6496529ff..6fe7d518800 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -11,11 +11,15 @@ namespace Aspire.Cli.DotNet; /// internal sealed class ProcessExecution : IProcessExecution { + private static readonly TimeSpan s_forwarderIdleTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_forwarderPollInterval = TimeSpan.FromMilliseconds(100); + private readonly Process _process; private readonly ILogger _logger; private readonly ProcessInvocationOptions _options; private Task? _stdoutForwarder; private Task? _stderrForwarder; + private long _lastForwarderActivityTimestamp = Stopwatch.GetTimestamp(); internal ProcessExecution(Process process, ILogger logger, ProcessInvocationOptions options) { @@ -52,6 +56,7 @@ public bool Start() } _logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", FileName, _process.Id, _process.StartInfo.WorkingDirectory); + RecordForwarderActivity(); // Start stream forwarders _stdoutForwarder = Task.Run(async () => @@ -90,30 +95,26 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); } - // Explicitly close the streams to unblock any pending ReadLineAsync calls. - // In some environments (particularly CI containers), the stream handles may not - // be automatically closed when the process exits, causing ReadLineAsync to block - // indefinitely. Disposing the streams forces them to close. - _logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams", FileName, _process.Id); - _process.StandardOutput.Close(); - _process.StandardError.Close(); - - // Wait for all the stream forwarders to finish so we know we've got everything - // fired off through the callbacks. Use a timeout as a safety net in case - // something else is unexpectedly holding the streams open. + // Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit. + RecordForwarderActivity(); + + // Wait for the stream forwarders to drain naturally first so we don't cut off the + // tail of the process output. In some environments the stream handles can stay open + // after the process exits, so we fall back to closing them only if the forwarders + // stop making progress for the idle timeout. if (_stdoutForwarder is not null && _stderrForwarder is not null) { - var forwarderTimeout = Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]); - - var completedTask = await Task.WhenAny(forwardersCompleted, forwarderTimeout); - if (completedTask == forwarderTimeout) - { - _logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within timeout after stream close", FileName, _process.Id); - } - else + if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) { - _logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id); + _logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder idle timeout", FileName, _process.Id); + _process.StandardOutput.Close(); + _process.StandardError.Close(); + + if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) + { + _logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within idle timeout after stream close", FileName, _process.Id); + } } } @@ -146,6 +147,8 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi string? line; while ((line = await reader.ReadLineAsync()) is not null) { + RecordForwarderActivity(); + if (_logger.IsEnabled(LogLevel.Trace)) { _logger.LogTrace( @@ -157,6 +160,7 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi ); } lineCallback?.Invoke(line); + RecordForwarderActivity(); } } catch (ObjectDisposedException) @@ -165,4 +169,36 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi _logger.LogDebug("{FileName}({ProcessId}) {Identifier} stream forwarder completed - stream was closed", FileName, _process.Id, identifier); } } + + private async Task WaitForForwardersAsync(Task forwardersCompleted, CancellationToken cancellationToken) + { + while (true) + { + if (forwardersCompleted.IsCompleted) + { + await forwardersCompleted.ConfigureAwait(false); + _logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id); + return true; + } + + if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastForwarderActivityTimestamp)) >= s_forwarderIdleTimeout) + { + return false; + } + + try + { + await Task.Delay(s_forwarderPollInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; + } + } + } + + private void RecordForwarderActivity() + { + Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp()); + } } diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index c08948a2d5b..c7484ffc7a7 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -5,6 +5,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; +using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -43,13 +44,16 @@ public Task> GetChannelsAsync(CancellationToken canc foreach (var prHive in prHives) { // The packages subdirectory contains the actual .nupkg files + var packagesDirectory = new DirectoryInfo(Path.Combine(prHive.FullName, "packages")); + var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory); + // Use forward slashes for cross-platform NuGet config compatibility - var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/'); + var packagesPath = packagesDirectory.FullName.Replace('\\', '/'); var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Both, new[] { new PackageMapping("Aspire*", packagesPath), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache); + }, nuGetPackageCache, pinnedVersion: pinnedVersion); prPackageChannels.Add(prChannel); } @@ -179,4 +183,32 @@ private PackageChannelQuality GetStagingQuality() var plusIndex = cliVersion.IndexOf('+'); return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion; } + + // Local hive channels point at a flat directory of .nupkg files instead of a searchable feed. + // Derive a concrete Aspire version from the hive contents and pin the channel to it so template + // and package resolution stays on the same locally built version instead of asking NuGet for "latest". + // Prefer Aspire.ProjectTemplates because it drives `aspire new`, then fall back to common packages + // that are still present when the templates package is absent. + private static string? GetLocalHivePinnedVersion(DirectoryInfo packagesDirectory) + { + if (!packagesDirectory.Exists) + { + return null; + } + + return FindHighestVersion("Aspire.ProjectTemplates") + ?? FindHighestVersion("Aspire.Hosting") + ?? FindHighestVersion("Aspire.AppHost.Sdk"); + + string? FindHighestVersion(string packageId) + { + return packagesDirectory + .EnumerateFiles($"{packageId}.*.nupkg") + .Select(static file => file.Name) + .Select(fileName => fileName[(packageId.Length + 1)..^".nupkg".Length]) + .Where(version => SemVersion.TryParse(version, SemVersionStyles.Strict, out _)) + .OrderByDescending(version => SemVersion.Parse(version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer) + .FirstOrDefault(); + } + } } diff --git a/src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj b/src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj index ffdcba714f2..56d868ba501 100644 --- a/src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj +++ b/src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj @@ -65,7 +65,6 @@ - diff --git a/src/Shared/BackchannelConstants.cs b/src/Shared/BackchannelConstants.cs index af06aabba44..88654de391b 100644 --- a/src/Shared/BackchannelConstants.cs +++ b/src/Shared/BackchannelConstants.cs @@ -354,7 +354,6 @@ public static int CleanupOrphanedSockets(string backchannelsDirectory, string ha return deleted; } - /// /// Computes a compact stable identifier from a string value. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 09a2155a8f0..9a28ee8691c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -26,10 +26,10 @@ public sealed class AgentCommandTests(ITestOutputHelper output) public async Task AgentCommands_AllHelpOutputs_AreCorrect() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -38,7 +38,7 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Test 1: aspire agent --help await auto.TypeAsync("aspire agent --help"); @@ -88,10 +88,10 @@ await auto.WaitUntilAsync( public async Task AgentInitCommand_MigratesDeprecatedConfig() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -105,7 +105,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, 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 @@ -163,10 +163,10 @@ await auto.WaitUntilAsync( public async Task DoctorCommand_DetectsDeprecatedAgentConfig() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -177,7 +177,7 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create deprecated config file File.WriteAllText(configPath, """{"mcpServers":{"aspire":{"command":"aspire","args":["mcp","start"]}}}"""); @@ -203,10 +203,10 @@ await auto.WaitUntilAsync( public async Task AgentInitCommand_DefaultSelection_InstallsSkillOnly() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -218,7 +218,7 @@ public async Task AgentInitCommand_DefaultSelection_InstallsSkillOnly() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create .vscode folder so the scanner detects VS Code environment Directory.CreateDirectory(vscodePath); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index d8f3128413d..fcd8cb111f0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -19,10 +19,10 @@ public sealed class BannerTests(ITestOutputHelper output) public async Task Banner_DisplayedOnFirstRun() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -31,7 +31,7 @@ public async Task Banner_DisplayedOnFirstRun() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Delete the first-time use sentinel file to simulate first run // The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel @@ -60,10 +60,10 @@ await auto.WaitUntilAsync( public async Task Banner_DisplayedWithExplicitFlag() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -72,7 +72,7 @@ public async Task Banner_DisplayedWithExplicitFlag() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Clear screen to have a clean slate for pattern matching await auto.ClearScreenAsync(counter); @@ -92,10 +92,10 @@ await auto.WaitUntilAsync( public async Task Banner_NotDisplayedWithNoLogoFlag() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -104,7 +104,7 @@ public async Task Banner_NotDisplayedWithNoLogoFlag() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Delete the first-time use sentinel file to simulate first run, // but use --nologo to suppress the banner diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs index f098611595c..2cbc7c08d9c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs @@ -15,15 +15,16 @@ namespace Aspire.Cli.EndToEnd.Tests; /// public sealed class BundleSmokeTests(ITestOutputHelper output) { + [CaptureWorkspaceOnFailure] [Fact] public async Task CreateAndRunAspireStarterProjectWithBundle() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -31,31 +32,12 @@ public async Task CreateAndRunAspireStarterProjectWithBundle() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.AspireNewAsync("BundleStarterApp", counter); - // 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)); - - // 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); - - 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); - - // 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 auto.AspireStartAsync(counter); + await auto.AspireStopAsync(counter); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs index 099501c4689..28381e0bde2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -21,10 +21,10 @@ public sealed class CentralPackageManagementTests(ITestOutputHelper output) public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -33,7 +33,7 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Disable update notifications to prevent the CLI self-update prompt // from appearing after "Update successful!" and blocking the test. @@ -124,10 +124,10 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP public async Task AspireAddPackageVersionToDirectoryPackagesProps() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -136,7 +136,7 @@ public async Task AspireAddPackageVersionToDirectoryPackagesProps() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Set up an AppHost project with CPM, but no installed packages var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmTest"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs index 6f35627dcc5..4e975ce55aa 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs @@ -18,10 +18,10 @@ public sealed class CertificatesCommandTests(ITestOutputHelper output) public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Generate dev certs WITHOUT trust (creates untrusted cert) await auto.TypeAsync("dotnet dev-certs https 2>/dev/null || true"); @@ -62,10 +62,10 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate() public async Task CertificatesClean_RemovesCertificates() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -73,7 +73,7 @@ public async Task CertificatesClean_RemovesCertificates() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Generate dev certs first await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https"); @@ -105,10 +105,10 @@ public async Task CertificatesClean_RemovesCertificates() public async Task CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -116,7 +116,7 @@ public async Task CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Configure SSL_CERT_DIR so trust detection works properly await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\""); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs index 5982089a9f9..4bd61727a4a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs @@ -31,11 +31,11 @@ public sealed class ConfigDiscoveryTests(ITestOutputHelper output) public async Task RunFromParentDirectory_UsesExistingConfigNearAppHost() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( - repoRoot, installMode, output, + repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); @@ -46,7 +46,7 @@ public async Task RunFromParentDirectory_UsesExistingConfigNearAppHost() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); const string projectName = "ConfigTest"; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs index e680a849820..e161dbbb75d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs @@ -25,12 +25,12 @@ public sealed class ConfigHealingTests(ITestOutputHelper output) public async Task InvalidAppHostPathWithComments_IsHealedOnRun() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( - repoRoot, installMode, output, + repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); @@ -40,7 +40,7 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // 1. Create a starter project await auto.AspireNewAsync("HealTest", counter, useRedisCache: false); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs index 3214c111cd5..e25a39af046 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs @@ -38,7 +38,7 @@ public sealed class ConfigMigrationTests(ITestOutputHelper output) /// private (string AspireHomeDir, Hex1bTerminal Terminal) CreateMigrationTerminal( string repoRoot, - CliE2ETestHelpers.DockerInstallMode installMode, + CliInstallStrategy strategy, TemporaryWorkspace workspace, [System.Runtime.CompilerServices.CallerMemberName] string testName = "") { @@ -47,7 +47,7 @@ public sealed class ConfigMigrationTests(ITestOutputHelper output) Directory.CreateDirectory(aspireHomeDir); var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( - repoRoot, installMode, output, + repoRoot, strategy, output, workspace: workspace, additionalVolumes: [$"{aspireHomeDir}:/root/.aspire"], testName: testName); @@ -108,10 +108,10 @@ private static void AssertFileDoesNotContain(string filePath, params string[] un public async Task GlobalSettings_MigratedFromLegacyFormat() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -119,7 +119,7 @@ public async Task GlobalSettings_MigratedFromLegacyFormat() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Pre-populate legacy globalsettings.json on the host (visible in container via bind mount). var legacyPath = Path.Combine(aspireHomeDir, "globalsettings.json"); @@ -173,10 +173,10 @@ public async Task GlobalSettings_MigratedFromLegacyFormat() public async Task GlobalMigration_SkipsWhenNewConfigExists() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -184,7 +184,7 @@ public async Task GlobalMigration_SkipsWhenNewConfigExists() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Pre-populate BOTH files on the host: aspire.config.json with "preview", // globalsettings.json with "staging". @@ -223,10 +223,10 @@ public async Task GlobalMigration_SkipsWhenNewConfigExists() public async Task GlobalMigration_HandlesMalformedLegacyJson() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -234,7 +234,7 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Write malformed JSON to the legacy file. var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json"); @@ -282,10 +282,10 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson() public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -293,7 +293,7 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Write legacy JSON with comments and trailing commas. var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json"); @@ -352,10 +352,10 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() public async Task ConfigSetGet_CreatesNestedJsonFormat() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -363,7 +363,7 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Ensure clean state. var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json"); @@ -436,10 +436,10 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat() public async Task GlobalMigration_PreservesAllValueTypes() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -447,7 +447,7 @@ public async Task GlobalMigration_PreservesAllValueTypes() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a comprehensive legacy globalsettings.json with all value types. var newConfigPath = Path.Combine(aspireHomeDir, "aspire.config.json"); @@ -522,10 +522,10 @@ public async Task GlobalMigration_PreservesAllValueTypes() public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, installMode, workspace); + var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -566,7 +566,7 @@ public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings() await auto.WaitForSuccessPromptAsync(counter); // Step 3: Install the new CLI (from this PR), overwriting the legacy CLI. - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 4: Run the new CLI to trigger global migration. await auto.TypeAsync("aspire --version"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DashboardOtelTracesTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DashboardOtelTracesTests.cs index 2b2e11703b3..df87dee5ead 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DashboardOtelTracesTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DashboardOtelTracesTests.cs @@ -19,11 +19,11 @@ public sealed class DashboardOtelTracesTests(ITestOutputHelper output) public async Task DashboardRunWithOtelTracesReturnsNoTraces() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: false, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -31,7 +31,7 @@ public async Task DashboardRunWithOtelTracesReturnsNoTraces() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Store the dashboard log path inside the workspace so it gets captured on failure var dashboardLogPath = $"/workspace/{workspace.WorkspaceRoot.Name}/dashboard.log"; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs index 9d8ed4b0f5c..5b41d119011 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs @@ -19,11 +19,11 @@ public sealed class DescribeCommandTests(ITestOutputHelper output) public async Task DescribeCommandShowsRunningResources() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -32,7 +32,7 @@ public async Task DescribeCommandShowsRunningResources() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("AspireResourcesTestApp", counter); @@ -89,11 +89,11 @@ public async Task DescribeCommandShowsRunningResources() public async Task DescribeCommandResolvesReplicaNames() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -106,7 +106,7 @@ public async Task DescribeCommandResolvesReplicaNames() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("AspireReplicaTestApp", counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index 942458ca9e2..bac16e1e65e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -23,25 +23,23 @@ public sealed class DockerDeploymentTests(ITestOutputHelper output) [QuarantinedTest("https://github.com/microsoft/aspire/issues/15882")] public async Task CreateAndDeployToDockerCompose() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - // PrepareEnvironment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } @@ -58,14 +56,7 @@ public async Task CreateAndDeployToDockerCompose() 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) - { - await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); // select first version (PR build) - } - - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromSeconds(180)); // Step 4: Modify AppHost's main file to add Docker Compose environment // Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs @@ -124,9 +115,8 @@ public async Task CreateAndDeployToDockerCompose() 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 - 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'"); + // Step 10: Verify the frontend responds from inside its own network namespace. + await auto.TypeAsync("container=$(docker ps --filter 'name=webfrontend' --format '{{.ID}}' | head -1) && docker run --rm --network container:$container curlimages/curl:8.12.1 -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || echo 'request-failed'"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); @@ -144,25 +134,23 @@ public async Task CreateAndDeployToDockerCompose() [QuarantinedTest("https://github.com/microsoft/aspire/issues/15871")] public async Task CreateAndDeployToDockerComposeInteractive() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - // PrepareEnvironment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } @@ -179,14 +167,7 @@ public async Task CreateAndDeployToDockerComposeInteractive() 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) - { - await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); // select first version (PR build) - } - - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromSeconds(180)); // Step 4: Modify AppHost's main file to add Docker Compose environment // Note: Aspire templates use AppHost.cs as the main entry point, not Program.cs @@ -246,9 +227,8 @@ public async Task CreateAndDeployToDockerComposeInteractive() 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 - 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'"); + // Step 10: Verify the frontend responds from inside its own network namespace. + await auto.TypeAsync("container=$(docker ps --filter 'name=webfrontend' --format '{{.ID}}' | head -1) && docker run --rm --network container:$container curlimages/curl:8.12.1 -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || echo 'request-failed'"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index de658b1dd95..29eee7efb4e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -18,10 +18,10 @@ public sealed class DoctorCommandTests(ITestOutputHelper output) public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +30,7 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Generate and trust dev certs inside the container (Docker images don't have them by default) await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https"); @@ -57,10 +57,10 @@ await auto.WaitUntilAsync( public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -69,7 +69,7 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Generate and trust dev certs inside the container (Docker images don't have them by default) await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index 61321a53991..38659df5539 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -14,14 +14,15 @@ namespace Aspire.Cli.EndToEnd.Tests; /// public sealed class EmptyAppHostTemplateTests(ITestOutputHelper output) { + [CaptureWorkspaceOnFailure] [Fact] public async Task CreateAndRunEmptyAppHostProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +30,7 @@ public async Task CreateAndRunEmptyAppHostProject() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.AspireNewAsync("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/verify.sh b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/verify.sh index 753b2c55f7b..cb74593ed67 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/verify.sh +++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/verify.sh @@ -10,34 +10,78 @@ mkdir -p "$DIAG" docker ps -a > "$DIAG/docker-ps.txt" 2>&1 docker images > "$DIAG/docker-images.txt" 2>&1 +resolve_published_host() { + if curl -sf --max-time 2 http://localhost:3001/api/weather > /dev/null 2>&1; then + echo "localhost" + return + fi + + if getent hosts host.docker.internal > /dev/null 2>&1 && \ + curl -sf --max-time 2 http://host.docker.internal:3001/api/weather > /dev/null 2>&1; then + echo "host.docker.internal" + return + fi + + GATEWAY_HEX=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route 2>/dev/null) + if [ -n "$GATEWAY_HEX" ]; then + GATEWAY=$(printf '%d.%d.%d.%d' \ + "0x${GATEWAY_HEX:6:2}" \ + "0x${GATEWAY_HEX:4:2}" \ + "0x${GATEWAY_HEX:2:2}" \ + "0x${GATEWAY_HEX:0:2}") + if curl -sf --max-time 2 "http://$GATEWAY:3001/api/weather" > /dev/null 2>&1; then + echo "$GATEWAY" + return + fi + fi + + echo "localhost" +} + +PUBLISHED_HOST=$(resolve_published_host) +echo "published_host=$PUBLISHED_HOST" >> "$DIAG/ports.txt" + get_port() { docker ps --filter "name=$1" --format '{{.Ports}}' | grep -oP '0.0.0.0:\K[0-9]+' | head -1 } +wait_for_url() { + URL=$1 + for i in $(seq 1 30); do + curl -sf --max-time 5 "$URL" > /dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + # Wait for API to be ready for i in $(seq 1 30); do - curl -sf http://localhost:3001/ > /dev/null 2>&1 && break + curl -sf "http://$PUBLISHED_HOST:3001/api/weather" > /dev/null 2>&1 && break sleep 1 done # Phase 1: Capture all responses (no -f, so non-200 still writes output) -curl -s http://localhost:3001/ > "$DIAG/api-response.txt" 2>&1 +curl -s "http://$PUBLISHED_HOST:3001/api/weather" > "$DIAG/api-response.txt" 2>&1 SP=$(get_port staticsite) echo "staticsite=$SP" >> "$DIAG/ports.txt" -curl -s "http://localhost:$SP/index.html" > "$DIAG/staticsite-response.txt" 2>&1 +wait_for_url "http://$PUBLISHED_HOST:$SP/index.html" || true +curl -s "http://$PUBLISHED_HOST:$SP/index.html" > "$DIAG/staticsite-response.txt" 2>&1 NP=$(get_port nodeserver) echo "nodeserver=$NP" >> "$DIAG/ports.txt" -curl -s "http://localhost:$NP/" > "$DIAG/nodeserver-response.txt" 2>&1 +wait_for_url "http://$PUBLISHED_HOST:$NP/" || true +curl -s "http://$PUBLISHED_HOST:$NP/" > "$DIAG/nodeserver-response.txt" 2>&1 MP=$(get_port npmscript) echo "npmscript=$MP" >> "$DIAG/ports.txt" -curl -s "http://localhost:$MP/" > "$DIAG/npmscript-response.txt" 2>&1 +wait_for_url "http://$PUBLISHED_HOST:$MP/" || true +curl -s "http://$PUBLISHED_HOST:$MP/" > "$DIAG/npmscript-response.txt" 2>&1 XP=$(get_port nextjs) echo "nextjs=$XP" >> "$DIAG/ports.txt" -curl -s "http://localhost:$XP/" > "$DIAG/nextjs-response.txt" 2>&1 +wait_for_url "http://$PUBLISHED_HOST:$XP/" || true +curl -s "http://$PUBLISHED_HOST:$XP/" > "$DIAG/nextjs-response.txt" 2>&1 # Capture container logs for c in $(docker ps --format '{{.Names}}'); do diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CaptureWorkspaceOnFailureAttribute.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CaptureWorkspaceOnFailureAttribute.cs index 8dc12d480a0..73e5a6711bd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CaptureWorkspaceOnFailureAttribute.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CaptureWorkspaceOnFailureAttribute.cs @@ -1,6 +1,7 @@ // 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 System.Reflection; using Xunit; using Xunit.v3; @@ -16,6 +17,7 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; /// /// "WorkspacePath" — the primary workspace directory /// "CapturePath:{label}" — additional directories to capture under the given label +/// "CaptureFile:{fileName}" — additional files to capture under the given destination name /// /// Workspace capture is automatic when using . /// @@ -23,10 +25,25 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] internal sealed class CaptureWorkspaceOnFailureAttribute : BeforeAfterTestAttribute { + public override void Before(MethodInfo methodUnderTest, IXunitTest test) + { + _ = methodUnderTest; + _ = test; + + TestContext.Current?.KeyValueStorage["PreserveWorkspaceOnFailure"] = true; + } + public override void After(MethodInfo methodUnderTest, IXunitTest test) { if (TestContext.Current.TestState?.Result is not TestResult.Failed) { + if (!CliE2ETestHelpers.IsRunningInCI && + TestContext.Current.KeyValueStorage.TryGetValue("WorkspacePath", out var workspaceValue) && + workspaceValue is string preservedWorkspacePath) + { + TemporaryWorkspace.ReleasePreservation(preservedWorkspacePath); + } + return; } @@ -34,12 +51,43 @@ public override void After(MethodInfo methodUnderTest, IXunitTest test) try { + if (!CliE2ETestHelpers.IsRunningInCI) + { + if (TestContext.Current.KeyValueStorage.TryGetValue("WorkspacePath", out var workspaceValue) && + workspaceValue is string localWorkspacePath) + { + Console.WriteLine($"Failed test workspace preserved at: {localWorkspacePath}"); + } + + foreach (var kvp in TestContext.Current.KeyValueStorage) + { + if (kvp.Key.StartsWith("CapturePath:", StringComparison.Ordinal) && + kvp.Value is string path && + Directory.Exists(path)) + { + var label = kvp.Key["CapturePath:".Length..]; + Console.WriteLine($"Failed test diagnostics '{label}' available at: {path}"); + } + + if (kvp.Key.StartsWith("CaptureFile:", StringComparison.Ordinal) && + kvp.Value is string filePath && + File.Exists(filePath)) + { + var fileName = kvp.Key["CaptureFile:".Length..]; + Console.WriteLine($"Failed test file '{fileName}' available at: {filePath}"); + } + } + + return; + } + // Capture primary workspace if (TestContext.Current.KeyValueStorage.TryGetValue("WorkspacePath", out var value) && value is string workspacePath && Directory.Exists(workspacePath)) { - CliE2ETestHelpers.CaptureDirectory(workspacePath, testName, label: null); + var capturePath = CliE2ETestHelpers.CaptureDirectory(workspacePath, testName, label: null); + Console.WriteLine($"Captured failed test workspace to: {capturePath}"); } // Capture additional registered paths (e.g., "CapturePath:aspire-home" → ~/.aspire) @@ -50,7 +98,17 @@ kvp.Value is string path && Directory.Exists(path)) { var label = kvp.Key["CapturePath:".Length..]; - CliE2ETestHelpers.CaptureDirectory(path, testName, label); + var capturePath = CliE2ETestHelpers.CaptureDirectory(path, testName, label); + Console.WriteLine($"Captured failed test diagnostics '{label}' to: {capturePath}"); + } + + if (kvp.Key.StartsWith("CaptureFile:", StringComparison.Ordinal) && + kvp.Value is string filePath && + File.Exists(filePath)) + { + var fileName = kvp.Key["CaptureFile:".Length..]; + var capturePath = CliE2ETestHelpers.CaptureFile(filePath, testName, fileName); + Console.WriteLine($"Captured failed test file '{fileName}' to: {capturePath}"); } } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 62b29ece100..0965e2ef535 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Xml.Linq; using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; +using Xunit; namespace Aspire.Cli.EndToEnd.Tests.Helpers; @@ -12,8 +14,14 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; /// Extension methods for providing Docker E2E test helpers. /// These parallel the -based methods in . /// +/// +/// These helpers are intentionally bash-first and Linux-specific. The tests drive a real terminal session, so the +/// implementation keeps the shell commands visible instead of abstracting every step behind helper layers. That keeps +/// the code, the asciinema recording, and the failure output aligned when a scenario needs debugging. +/// internal static class CliE2EAutomatorHelpers { + private const string AspireStartJsonFile = "/tmp/aspire-start.json"; private static readonly string s_expectedStableVersionMarker = GetExpectedStableVersionMarker(); /// @@ -29,7 +37,8 @@ internal static async Task PrepareDockerEnvironmentAsync( await auto.WaitAsync(500); - // Set up the prompt counting mechanism + // Install the numbered prompt contract used throughout these tests. The prompt encodes both the command + // sequence number and the exit code so waits can synchronize on shell completion instead of timing guesses. 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(); @@ -47,14 +56,30 @@ internal static async Task PrepareDockerEnvironmentAsync( if (workspace is not null) { - await auto.TypeAsync($"cd /workspace/{workspace.WorkspaceRoot.Name}"); + var containerWorkspace = $"/workspace/{workspace.WorkspaceRoot.Name}"; + + await auto.TypeAsync($"cd {containerWorkspace}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Set up EXIT trap to copy .aspire diagnostics to workspace for CI capture - await auto.TypeAsync($"trap 'cp -r ~/.aspire/logs /workspace/{workspace.WorkspaceRoot.Name}/.aspire-logs 2>/dev/null; cp -r ~/.aspire/packages /workspace/{workspace.WorkspaceRoot.Name}/.aspire-packages 2>/dev/null' EXIT"); + await auto.TypeAsync($"export ASPIRE_E2E_WORKSPACE={containerWorkspace}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); + + if (!CliE2ETestHelpers.IsRunningInCI && ShouldPreserveLocalWorkspace()) + { + workspace.Preserve(); + } + + if (ShouldCaptureWorkspaceDiagnostics()) + { + await auto.TypeAsync( + "trap 'if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " + + BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") + + "fi' EXIT"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } } } @@ -88,7 +113,7 @@ internal static async Task InstallAspireCliInDockerAsync( case CliE2ETestHelpers.DockerInstallMode.PullRequest: var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}"); + await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {GetPullRequestInstallArgs(prNumber)}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH"); @@ -114,7 +139,7 @@ internal static async Task InstallAspireCliAsync( { case CliInstallMode.LocalHive: // Extract the localhive archive into ~/.aspire - await auto.TypeAsync("mkdir -p ~/.aspire && tar -xzf /tmp/aspire-localhive.tar.gz -C ~/.aspire"); + await auto.TypeAsync("mkdir -p ~/.aspire && tar -xzf /tmp/aspire-localhive.tar.gz -C ~/.aspire 2>/dev/null"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH"); @@ -124,11 +149,15 @@ internal static async Task InstallAspireCliAsync( await auto.TypeAsync("aspire config set channel local -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); + // Set SDK version from the hive packages so aspire new uses the local version + await auto.TypeAsync("SDK_VER=$(ls ~/.aspire/hives/local/packages/Aspire.Hosting.*.nupkg 2>/dev/null | head -1 | sed 's/.*Aspire\\.Hosting\\.//;s/\\.nupkg//') && aspire config set sdk.version \"$SDK_VER\" -g"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); break; case CliInstallMode.PullRequest: var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}"); + await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {GetPullRequestInstallArgs(prNumber)}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH"); @@ -137,16 +166,7 @@ internal static async Task InstallAspireCliAsync( break; case CliInstallMode.InstallScript: - var scriptArgs = ""; - if (strategy.Quality is not null) - { - scriptArgs = $" --quality {strategy.Quality.Value.ToString().ToLowerInvariant()}"; - } - else if (strategy.Version is not null) - { - scriptArgs = $" --version {strategy.Version}"; - } - await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli.sh{scriptArgs}"); + await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli.sh{GetInstallScriptArgs(strategy)}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(120)); await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH"); @@ -164,6 +184,91 @@ internal static async Task InstallAspireCliAsync( await auto.WaitForSuccessPromptAsync(counter); } + /// + /// Installs the Aspire CLI in a non-Docker shell using the given install strategy. + /// + internal static async Task InstallAspireCliInShellAsync( + this Hex1bTerminalAutomator auto, + CliInstallStrategy strategy, + SequenceCounter counter) + { + switch (strategy.Mode) + { + case CliInstallMode.LocalHive: + var archivePath = QuoteBashArg(strategy.ArchivePath ?? throw new InvalidOperationException("LocalHive strategy is missing the archive path.")); + await auto.TypeAsync($"mkdir -p ~/.aspire && tar -xzf {archivePath} -C ~/.aspire 2>/dev/null"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + await auto.SourceAspireCliEnvironmentAsync(counter); + await auto.TypeAsync("aspire config set channel local -g"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + await auto.TypeAsync("SDK_VER=$(ls ~/.aspire/hives/local/packages/Aspire.Hosting.*.nupkg 2>/dev/null | head -1 | sed 's/.*Aspire\\.Hosting\\.//;s/\\.nupkg//') && aspire config set sdk.version \"$SDK_VER\" -g"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + break; + + case CliInstallMode.PullRequest: + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + await auto.SourceAspireCliEnvironmentAsync(counter); + break; + + case CliInstallMode.InstallScript: + var getAspireCliScript = QuoteBashArg(Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "scripts", "get-aspire-cli.sh")); + await auto.TypeAsync($"bash {getAspireCliScript}{GetInstallScriptArgs(strategy)}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(120)); + await auto.SourceAspireCliEnvironmentAsync(counter); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(strategy), strategy.Mode, "Unknown install mode"); + } + + await auto.TypeAsync("aspire --version"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + + /// + /// Completes an aspire add command whether it finishes directly or shows a version selection prompt first. + /// + internal static async Task WaitForAspireAddSuccessAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(180); + var sawVersionPrompt = false; + + await auto.WaitUntilAsync(snapshot => + { + var versionPromptSearcher = new CellPatternSearcher().Find("(based on NuGet.config)"); + if (versionPromptSearcher.Search(snapshot).Count > 0) + { + sawVersionPrompt = true; + return true; + } + + var successSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + var errorSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" ERR:"); + + return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0; + }, timeout: effectiveTimeout, description: $"aspire add completion or version prompt [{counter.Value}]"); + + if (sawVersionPrompt) + { + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); + } + /// /// Mounts the workspace-local Aspire package hive into the standard local hive path inside Docker. /// Used for SourceBuild E2E runs where the CLI binary is local but package resolution still needs @@ -204,6 +309,7 @@ await auto.WaitUntilAsync( description: "initial bash prompt"); await auto.WaitAsync(500); + // Use the same numbered prompt contract as the Docker helpers so the same wait helpers work in both modes. 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(); @@ -212,6 +318,25 @@ await auto.WaitUntilAsync( await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync($"export ASPIRE_E2E_WORKSPACE=\"{workspace.WorkspaceRoot.FullName}\""); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + if (!CliE2ETestHelpers.IsRunningInCI && ShouldPreserveLocalWorkspace()) + { + workspace.Preserve(); + } + + if (ShouldCaptureWorkspaceDiagnostics()) + { + await auto.TypeAsync( + "trap 'if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " + + BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") + + "fi' EXIT"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } } /// @@ -222,7 +347,7 @@ internal static async Task InstallAspireCliFromPullRequestAsync( int prNumber, SequenceCounter counter) { - var command = $"curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; + var command = $"curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {GetPullRequestInstallArgs(prNumber)}"; await auto.TypeAsync(command); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); @@ -283,6 +408,23 @@ private static string GetExpectedStableVersionMarker() : throw new InvalidOperationException($"Could not determine Aspire version marker from '{versionsPropsPath}'."); } + private static string GetInstallScriptArgs(CliInstallStrategy strategy) + { + if (strategy.Quality is not null) + { + return $" --quality {strategy.Quality.Value.ToString().ToLowerInvariant()}"; + } + + return strategy.Version is not null + ? $" --version {strategy.Version}" + : ""; + } + + private static string QuoteBashArg(string value) + { + return $"'{value.Replace("'", "'\"'\"'")}'"; + } + /// /// Installs the Aspire CLI and bundle from PR build artifacts, using the PR head SHA to fetch the install script. /// @@ -291,12 +433,21 @@ internal static async Task InstallAspireBundleFromPullRequestAsync( int prNumber, SequenceCounter counter) { - var command = $"ref=$(gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha') && curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; + var command = $"ref=$(gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha') && curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {GetPullRequestInstallArgs(prNumber)}"; await auto.TypeAsync(command); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); } + internal static string GetPullRequestInstallArgs(int prNumber) + { + var workflowRunId = CliE2ETestHelpers.GetCliArchiveWorkflowRunId(); + + return workflowRunId is null + ? prNumber.ToString(CultureInfo.InvariantCulture) + : $"{prNumber.ToString(CultureInfo.InvariantCulture)} --run-id {workflowRunId}"; + } + /// /// 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. @@ -365,7 +516,8 @@ internal static async Task InstallAspireCliVersionAsync( /// Starts an Aspire AppHost with aspire start --format json, extracts the dashboard URL, /// and verifies the dashboard is reachable. Caller is responsible for calling /// when done. - /// On failure, dumps the latest CLI log file to the terminal output for debugging. + /// On failure, dumps the latest CLI log file to the terminal output and promotes the highest-signal + /// diagnostics into the workspace for artifact capture. /// internal static async Task AspireStartAsync( this Hex1bTerminalAutomator auto, @@ -373,14 +525,19 @@ internal static async Task AspireStartAsync( TimeSpan? startTimeout = null) { var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(3); - var jsonFile = "/tmp/aspire-start.json"; var expectedCounter = counter.Value; - - // Start with JSON output - await auto.TypeAsync($"aspire start --format json | tee {jsonFile}"); + // In CI the JSON transcript lives in /tmp first and is copied into the captured workspace on failure. + // Local runs write directly into the preserved workspace so the file is already where developers inspect it. + var jsonFile = CliE2ETestHelpers.IsRunningInCI + ? AspireStartJsonFile + : "$ASPIRE_E2E_WORKSPACE/_aspire-start.json"; + + // Keep aspire start as a single shell pipeline so tee captures the exact JSON emitted to the terminal while + // pipefail preserves the real CLI exit code instead of letting tee mask build/startup failures. + await auto.TypeAsync($"(set -o pipefail; aspire start --format json | tee \"{jsonFile}\")"); await auto.EnterAsync(); - // Wait for the command to finish — check for success or error exit + // Wait for the command to finish — check for success or error exit. var succeeded = false; await auto.WaitUntilAsync(snapshot => { @@ -403,41 +560,81 @@ await auto.WaitUntilAsync(snapshot => if (!succeeded) { - // Dump logs for debugging then fail await auto.TypeAsync( "LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); " + "echo '=== ASPIRE LOG ==='; " + "[ -n \"$LOG\" ] && tail -100 \"$LOG\"; " + "echo '=== END LOG ==='; " + - $"cat {jsonFile}"); + $"cat \"{jsonFile}\""); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - throw new InvalidOperationException("aspire start failed. Check terminal output for CLI logs."); + await auto.CaptureRegisteredWorkspaceDiagnosticsAsync(counter); + + var workspacePath = GetRegisteredWorkspacePath(); + throw new InvalidOperationException( + workspacePath is null || !ShouldCaptureWorkspaceDiagnostics() + ? "aspire start failed. Check terminal output for CLI logs." + : $"aspire start failed. Workspace: {workspacePath}. See _aspire-detach.log, _aspire-cli.log, .aspire-logs, and _aspire-start.json in the captured workspace."); } - // Extract dashboard URL and verify it's reachable. - // First check if the apphost crashed (aspire start exits 0 but prints error). await auto.TypeAsync( $"DASHBOARD_URL=$(sed -n " + "'s/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https\\?:\\/\\/localhost:[0-9]*\\).*/\\1/p' " + - $"{jsonFile} | head -1)"); + $"\"{jsonFile}\" | head -1)"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // If DASHBOARD_URL is empty, the apphost likely crashed — dump logs for diagnostics + var dashboardUrlCounter = counter.Value; + var dashboardUrlFound = false; + await auto.TypeAsync( "if [ -z \"$DASHBOARD_URL\" ]; then " + "echo 'dashboard-url-empty'; " + - "echo '=== ASPIRE START JSON ==='; cat " + jsonFile + "; echo '=== END JSON ==='; " + + "echo '=== ASPIRE START JSON ==='; cat \"" + jsonFile + "\"; echo '=== END JSON ==='; " + "echo '=== ALL LOGS ==='; ls -lt ~/.aspire/logs/ 2>/dev/null; echo '=== END LIST ==='; " + "DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1); " + "echo \"=== DETACH LOG: $DETACH_LOG ===\"; [ -n \"$DETACH_LOG\" ] && tail -200 \"$DETACH_LOG\"; echo '=== END DETACH ==='; " + - "CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); " + + "CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | grep -v 'detach' | head -1); " + + "if [ -z \"$CLI_LOG\" ]; then CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); fi; " + "echo \"=== CLI LOG: $CLI_LOG ===\"; [ -n \"$CLI_LOG\" ] && tail -100 \"$CLI_LOG\"; echo '=== END CLI ==='; " + + "false; " + + "else " + + "echo \"dashboard-url:$DASHBOARD_URL\"; " + "fi"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + + await auto.WaitUntilAsync(snapshot => + { + var successSearcher = new CellPatternSearcher() + .FindPattern(dashboardUrlCounter.ToString()) + .RightText(" OK] $ "); + if (successSearcher.Search(snapshot).Count > 0) + { + dashboardUrlFound = true; + return true; + } + + var errorSearcher = new CellPatternSearcher() + .FindPattern(dashboardUrlCounter.ToString()) + .RightText(" ERR:"); + return errorSearcher.Search(snapshot).Count > 0; + }, timeout: TimeSpan.FromSeconds(30), description: $"dashboard url validation [{dashboardUrlCounter} OK/ERR]"); + + counter.Increment(); + + if (!dashboardUrlFound) + { + // Missing dashboardUrl is the root startup failure we care about. Stop here so a later curl timeout + // doesn't replace the useful AppHost / detached-child diagnostics with a secondary HTTP symptom. + await auto.CaptureRegisteredWorkspaceDiagnosticsAsync(counter); + + var workspacePath = GetRegisteredWorkspacePath(); + throw new InvalidOperationException( + workspacePath is null || !ShouldCaptureWorkspaceDiagnostics() + ? "aspire start did not return a dashboard URL. Check terminal output for detached child and CLI logs." + : $"aspire start did not return a dashboard URL. Workspace: {workspacePath}. See _aspire-detach.log, _aspire-cli.log, .aspire-logs, and _aspire-start.json in the captured workspace."); + } await auto.TypeAsync( "curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" " + @@ -511,21 +708,25 @@ await auto.WaitUntilAsync(s => } /// - /// Copies interesting diagnostic directories from ~/.aspire to the mounted workspace - /// so they are captured by . Call this before - /// exiting the container. Copies logs and NuGet restore output (libs directories). + /// Copies interesting diagnostics from ~/.aspire to the workspace so they are captured by + /// . Call this before exiting the terminal. /// internal static async Task CaptureAspireDiagnosticsAsync( this Hex1bTerminalAutomator auto, SequenceCounter counter, TemporaryWorkspace workspace) { + if (!CliE2ETestHelpers.IsRunningInCI) + { + await auto.TypeAsync("echo diagnostics-available-in-workspace"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + return; + } + var containerWorkspace = $"/workspace/{workspace.WorkspaceRoot.Name}"; - // Copy CLI logs - await auto.TypeAsync($"cp -r ~/.aspire/logs {containerWorkspace}/.aspire-logs 2>/dev/null; " + - $"cp -r ~/.aspire/packages {containerWorkspace}/.aspire-packages 2>/dev/null; " + - "echo done"); + await auto.TypeAsync(BuildAspireDiagnosticsCaptureCommand(containerWorkspace) + "echo done"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } @@ -544,4 +745,61 @@ internal static async Task AspireDestroyAsync( await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: timeout.Value); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); } + + private static async Task CaptureRegisteredWorkspaceDiagnosticsAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + if (!ShouldCaptureWorkspaceDiagnostics()) + { + return; + } + + await auto.TypeAsync( + "if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " + + BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") + + "echo \"copied-failure-artifacts:$ASPIRE_E2E_WORKSPACE\"; " + + "fi"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + + private static string BuildAspireDiagnosticsCaptureCommand(string destinationExpression) + { + // This returns a single bash fragment because it is reused from EXIT traps and failure paths where the helper + // needs to inject one inline shell command rather than orchestrate several terminal round-trips. + return + $"mkdir -p \"{destinationExpression}\"; " + + $"rm -rf \"{destinationExpression}/.aspire-logs\" \"{destinationExpression}/.aspire-packages\"; " + + $"cp -r ~/.aspire/logs \"{destinationExpression}/.aspire-logs\" 2>/dev/null || true; " + + $"cp -r ~/.aspire/packages \"{destinationExpression}/.aspire-packages\" 2>/dev/null || true; " + + $"cp {AspireStartJsonFile} \"{destinationExpression}/_aspire-start.json\" 2>/dev/null || true; " + + "DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1); " + + $"[ -n \"$DETACH_LOG\" ] && cp \"$DETACH_LOG\" \"{destinationExpression}/_aspire-detach.log\" 2>/dev/null || true; " + + "CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | grep -v 'detach' | head -1); " + + "if [ -z \"$CLI_LOG\" ]; then CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); fi; " + + $"[ -n \"$CLI_LOG\" ] && cp \"$CLI_LOG\" \"{destinationExpression}/_aspire-cli.log\" 2>/dev/null || true; "; + } + + private static string? GetRegisteredWorkspacePath() + { + if (TestContext.Current?.KeyValueStorage.TryGetValue("WorkspacePath", out var value) == true && + value is string workspacePath) + { + return workspacePath; + } + + return null; + } + + private static bool ShouldPreserveLocalWorkspace() + { + return TestContext.Current?.KeyValueStorage.TryGetValue("PreserveWorkspaceOnFailure", out var value) == true && + value is true; + } + + private static bool ShouldCaptureWorkspaceDiagnostics() + { + return CliE2ETestHelpers.IsRunningInCI || ShouldPreserveLocalWorkspace(); + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 7fb8dfe9c7a..b12eddcf677 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -17,6 +17,8 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; /// internal static class CliE2ETestHelpers { + internal const string CliArchiveWorkflowRunIdEnvironmentVariableName = "ASPIRE_CLI_WORKFLOW_RUN_ID"; + /// /// Gets whether the tests are running in CI (GitHub Actions) vs locally. /// When running locally, some commands are replaced with echo stubs. @@ -63,10 +65,27 @@ internal static string GetRequiredCommitSha() return commitSha; } + /// + /// Gets the workflow run ID that produced the CLI archive for the current test run, if one was provided. + /// + /// The workflow run ID, or when the current environment should resolve the PR run dynamically. + internal static string? GetCliArchiveWorkflowRunId() + { + var runId = Environment.GetEnvironmentVariable(CliArchiveWorkflowRunIdEnvironmentVariableName); + + if (string.IsNullOrEmpty(runId)) + { + return null; + } + + Assert.True(long.TryParse(runId, out _), $"{CliArchiveWorkflowRunIdEnvironmentVariableName} must be a valid integer, got: {runId}"); + return runId; + } + /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// In CI, this returns a path under $GITHUB_WORKSPACE/testresults/recordings/. - /// Locally, this returns a path under the system temp directory. + /// Locally, this returns a path under the test output TestResults/recordings/ directory. /// /// The name of the test (used as the recording filename). /// The full path to the .cast recording file. @@ -85,7 +104,14 @@ internal static string GetTestResultsRecordingPath(string testName) /// A configured instance. Caller is responsible for disposal. internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") { - return Hex1bTestHelpers.CreateTestTerminal("aspire-cli-e2e", width, height, testName); + var recordingPath = GetTestResultsRecordingPath(testName); + RegisterCaptureFile("recording.cast", recordingPath); + return Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(width, height) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]) + .Build(); } /// @@ -131,8 +157,11 @@ internal enum DockerfileVariant } private const string PolyglotBaseImageName = "aspire-e2e-polyglot-base"; + private const string PodmanBaseImageName = "aspire-e2e-podman-base"; private static readonly object s_polyglotBaseImageLock = new(); + private static readonly object s_podmanBaseImageLock = new(); private static bool s_polyglotBaseImageBuilt; + private static bool s_podmanBaseImageBuilt; /// /// Detects the install mode for Docker-based tests based on the current environment. @@ -181,7 +210,11 @@ internal static DockerInstallMode DetectDockerInstallMode(string repoRoot) /// Creates a Hex1b terminal that runs inside a Docker container built from the shared E2E Dockerfile. /// The Dockerfile builds the CLI from source (local dev) or accepts pre-built artifacts (CI). /// - /// The repo root directory, used as the Docker build context. + /// + /// The repo root directory, used as the Docker build context and to locate the shared test Dockerfiles. + /// This is required for every Docker-based CLI E2E mode, not just localhive, because the terminal always runs + /// inside a purpose-built test container even when the CLI itself is later installed from PR artifacts or scripts. + /// /// The detected install mode, controlling Docker build args and volumes. /// Test output helper for logging configuration details. /// Which Dockerfile variant to use (DotNet or Polyglot). @@ -204,6 +237,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( [CallerMemberName] string testName = "") { var recordingPath = GetTestResultsRecordingPath(testName); + RegisterCaptureFile("recording.cast", recordingPath); var dockerfileName = variant switch { DockerfileVariant.DotNet => "Dockerfile.e2e", @@ -296,6 +330,11 @@ internal static Hex1bTerminal CreateDockerTestTerminal( /// Creates a Hex1b terminal that runs inside a Docker container, configured using the /// given for CLI installation. /// + /// + /// The install strategy decides how the CLI gets installed inside the container after startup. The container + /// itself is still built from the repository Docker context, so is not specific to + /// localhive scenarios. + /// internal static Hex1bTerminal CreateDockerTestTerminal( string repoRoot, CliInstallStrategy strategy, @@ -309,6 +348,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( [CallerMemberName] string testName = "") { var recordingPath = GetTestResultsRecordingPath(testName); + RegisterCaptureFile("recording.cast", recordingPath); var dockerfileName = variant switch { DockerfileVariant.DotNet => "Dockerfile.e2e", @@ -367,6 +407,67 @@ internal static Hex1bTerminal CreateDockerTestTerminal( return builder.Build(); } + /// + /// Creates a Hex1b terminal backed by a privileged Docker container that runs Podman internally. + /// + /// + /// This is used for Podman deployment tests so the nested Podman runtime stays isolated from the host machine + /// while still supporting the privileges required by Podman-in-container scenarios. + /// + internal static Hex1bTerminal CreatePodmanDockerTestTerminal( + string repoRoot, + CliInstallStrategy strategy, + ITestOutputHelper output, + TemporaryWorkspace? workspace = null, + IEnumerable? additionalVolumes = null, + int width = 160, + int height = 48, + [CallerMemberName] string testName = "") + { + var recordingPath = GetTestResultsRecordingPath(testName); + RegisterCaptureFile("recording.cast", recordingPath); + + EnsurePodmanBaseImage(repoRoot, output); + + var containerName = GenerateDockerContainerName(); + var options = new DockerContainerOptions + { + Image = PodmanBaseImageName, + WorkingDirectory = "/workspace", + }; + + if (workspace is not null) + { + options.Volumes.Add($"{workspace.WorkspaceRoot.FullName}:/workspace/{workspace.WorkspaceRoot.Name}"); + } + + if (additionalVolumes is not null) + { + foreach (var volume in additionalVolumes) + { + options.Volumes.Add(volume); + } + } + + strategy.ConfigureContainer(options); + + output.WriteLine("Creating Podman Docker test terminal:"); + output.WriteLine($" Test name: {testName}"); + output.WriteLine($" Strategy: {strategy}"); + output.WriteLine($" Image: {PodmanBaseImageName}"); + output.WriteLine($" Container name: {containerName}"); + output.WriteLine($" Workspace: {workspace?.WorkspaceRoot.FullName ?? "(none)"}"); + output.WriteLine($" Dimensions: {width}x{height}"); + output.WriteLine($" Recording: {recordingPath}"); + + return Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(width, height) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("docker", BuildPrivilegedDockerRunArgs(options, containerName)) + .Build(); + } + private static void EnsurePolyglotBaseImage(string repoRoot, ITestOutputHelper output) { lock (s_polyglotBaseImageLock) @@ -418,6 +519,114 @@ private static void EnsurePolyglotBaseImage(string repoRoot, ITestOutputHelper o } } + private static void EnsurePodmanBaseImage(string repoRoot, ITestOutputHelper output) + { + lock (s_podmanBaseImageLock) + { + if (s_podmanBaseImageBuilt) + { + return; + } + + var dockerfilePath = Path.Combine(repoRoot, "tests", "Shared", "Docker", "Dockerfile.e2e-podman"); + + output.WriteLine($"Building shared Podman Docker base image from {dockerfilePath}"); + + var startInfo = new ProcessStartInfo("docker") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + startInfo.ArgumentList.Add("build"); + startInfo.ArgumentList.Add("--quiet"); + startInfo.ArgumentList.Add("-f"); + startInfo.ArgumentList.Add(dockerfilePath); + startInfo.ArgumentList.Add("-t"); + startInfo.ArgumentList.Add(PodmanBaseImageName); + startInfo.ArgumentList.Add(repoRoot); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start docker build process."); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to build shared Podman Docker base image.{Environment.NewLine}" + + $"{standardOutput}{Environment.NewLine}{standardError}"); + } + + if (!string.IsNullOrWhiteSpace(standardOutput)) + { + output.WriteLine(standardOutput.Trim()); + } + + s_podmanBaseImageBuilt = true; + } + } + + private static string[] BuildPrivilegedDockerRunArgs(DockerContainerOptions options, string containerName) + { + var arguments = new List + { + "run", + "-it", + "--privileged" + }; + + if (options.AutoRemove) + { + arguments.Add("--rm"); + } + + arguments.Add("--name"); + arguments.Add(containerName); + + foreach (var (key, value) in options.Environment) + { + arguments.Add("-e"); + arguments.Add($"{key}={value}"); + } + + foreach (var volume in options.Volumes) + { + arguments.Add("-v"); + arguments.Add(volume); + } + + if (options.MountDockerSocket) + { + arguments.Add("-v"); + arguments.Add("/var/run/docker.sock:/var/run/docker.sock"); + } + + if (options.WorkingDirectory is not null) + { + arguments.Add("-w"); + arguments.Add(options.WorkingDirectory); + } + + if (options.Network is not null) + { + arguments.Add("--network"); + arguments.Add(options.Network); + } + + arguments.Add(options.Image); + arguments.Add(options.Shell); + arguments.AddRange(options.ShellArgs); + + return [.. arguments]; + } + + private static string GenerateDockerContainerName() + { + return $"hex1b-test-{Guid.NewGuid():N}".Substring(0, 32); + } + /// /// Walks up from the test assembly directory to find the repo root (contains Aspire.slnx). /// @@ -597,17 +806,23 @@ internal static void WriteLocalChannelSettings(string projectRoot, string sdkVer /// The Aspire SDK version extracted from the package filenames. internal sealed record LocalChannelInfo(string PackagesPath, string SdkVersion); + private static void RegisterCaptureFile(string fileName, string path) + { + if (TestContext.Current is null) + { + return; + } + + TestContext.Current.KeyValueStorage[$"CaptureFile:{Path.GetFileName(fileName)}"] = path; + } + /// /// Copies a directory to testresults/workspaces/{testName}/{label} for CI artifact upload. /// Renames dot-prefixed directories to underscore-prefixed (upload-artifact skips hidden files). /// - internal static void CaptureDirectory(string sourcePath, string testName, string? label) + internal static string CaptureDirectory(string sourcePath, string testName, string? label) { - var destDir = Path.Combine( - AppContext.BaseDirectory, - "TestResults", - "workspaces", - testName); + var destDir = GetCaptureRootDirectory(testName); if (label is not null) { @@ -619,6 +834,35 @@ internal static void CaptureDirectory(string sourcePath, string testName, string "_capture.log")); CopyDirectory(sourcePath, destDir, line => logWriter.WriteLine(line)); + return destDir; + } + + /// + /// Copies a file to testresults/workspaces/{testName}/ for CI artifact upload. + /// Hidden files are renamed to underscore-prefixed names for compatibility with artifact upload defaults. + /// + internal static string CaptureFile(string sourcePath, string testName, string fileName) + { + var destDir = Directory.CreateDirectory(GetCaptureRootDirectory(testName)).FullName; + var captureFileName = Path.GetFileName(fileName); + + if (captureFileName.StartsWith(".", StringComparison.Ordinal)) + { + captureFileName = "_" + captureFileName[1..]; + } + + var destFile = Path.Combine(destDir, captureFileName); + File.Copy(sourcePath, destFile, overwrite: true); + return destFile; + } + + private static string GetCaptureRootDirectory(string testName) + { + return Path.Combine( + AppContext.BaseDirectory, + "TestResults", + "workspaces", + testName); } private static void CopyDirectory(string sourceDir, string destDir, Action? log) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategy.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategy.cs index f2d4607b599..db02da4063e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategy.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategy.cs @@ -200,6 +200,11 @@ public void ConfigureContainer(Hex1b.DockerContainerOptions config) config.Environment["GITHUB_PR_NUMBER"] = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER") ?? ""; config.Environment["GITHUB_PR_HEAD_SHA"] = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA") ?? ""; + var workflowRunId = CliE2ETestHelpers.GetCliArchiveWorkflowRunId(); + if (!string.IsNullOrEmpty(workflowRunId)) + { + config.Environment[CliE2ETestHelpers.CliArchiveWorkflowRunIdEnvironmentVariableName] = workflowRunId; + } break; case CliInstallMode.InstallScript: diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs new file mode 100644 index 00000000000..788b3add876 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +[Collection(CliInstallEnvironmentCollection.Name)] +public class CliInstallStrategyTests +{ + [Fact] + public void GetPullRequestInstallArgs_UsesPrNumberWhenWorkflowRunIdIsMissing() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.CliArchiveWorkflowRunIdEnvironmentVariableName, null)); + + Assert.Equal("123", CliE2EAutomatorHelpers.GetPullRequestInstallArgs(123)); + } + + [Fact] + public void GetPullRequestInstallArgs_AppendsWorkflowRunIdWhenProvided() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.CliArchiveWorkflowRunIdEnvironmentVariableName, "987654321")); + + Assert.Equal("123 --run-id 987654321", CliE2EAutomatorHelpers.GetPullRequestInstallArgs(123)); + } + + [Fact] + public void ConfigureContainer_AddsWorkflowRunIdForPullRequestStrategy() + { + using var environment = new EnvironmentVariableScope( + ("ASPIRE_E2E_ARCHIVE", null), + ("ASPIRE_E2E_QUALITY", null), + ("ASPIRE_E2E_VERSION", null), + ("GITHUB_PR_NUMBER", "16131"), + ("GITHUB_PR_HEAD_SHA", "52669a7cac3d4f10c6269909fc38e77124ed177c"), + (CliE2ETestHelpers.CliArchiveWorkflowRunIdEnvironmentVariableName, "24404068249")); + + var strategy = CliInstallStrategy.Detect(); + var options = new DockerContainerOptions(); + + strategy.ConfigureContainer(options); + + Assert.Equal("24404068249", options.Environment[CliE2ETestHelpers.CliArchiveWorkflowRunIdEnvironmentVariableName]); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly Dictionary _originalValues; + + public EnvironmentVariableScope(params (string Name, string? Value)[] variables) + { + _originalValues = variables.ToDictionary( + variable => variable.Name, + variable => Environment.GetEnvironmentVariable(variable.Name)); + + foreach (var (name, value) in variables) + { + Environment.SetEnvironmentVariable(name, value); + } + } + + public void Dispose() + { + foreach (var (name, value) in _originalValues) + { + Environment.SetEnvironmentVariable(name, value); + } + } + } +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class CliInstallEnvironmentCollection +{ + public const string Name = nameof(CliInstallEnvironmentCollection); +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index eb8a06ecd3d..11d9c8986bc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -35,8 +35,8 @@ internal static async Task InstallKindAndHelmAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Download KinD if not already installed — GitHub CDN can transiently return HTML instead of binary - await auto.TypeAsync($"command -v kind >/dev/null 2>&1 || {{ for i in 1 2 3; do curl -sSLo ~/.local/bin/kind \"https://github.com/kubernetes-sigs/kind/releases/download/{KindVersion}/kind-linux-amd64\" && file ~/.local/bin/kind | grep -q ELF && break; echo \"Retry $i: KinD download failed, retrying in 5s...\"; sleep 5; done && chmod +x ~/.local/bin/kind; }}"); + // Download KinD if not already installed — GitHub CDN can transiently return HTML instead of a binary. + await auto.TypeAsync($"command -v kind >/dev/null 2>&1 || {{ rm -f ~/.local/bin/kind; for i in 1 2 3; do curl -sSLo ~/.local/bin/kind \"https://github.com/kubernetes-sigs/kind/releases/download/{KindVersion}/kind-linux-amd64\" && chmod +x ~/.local/bin/kind && ~/.local/bin/kind version >/dev/null 2>&1 && break; echo \"Retry $i: KinD download failed, retrying in 5s...\"; rm -f ~/.local/bin/kind; sleep 5; done; test -x ~/.local/bin/kind && ~/.local/bin/kind version >/dev/null 2>&1; }}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90)); @@ -46,7 +46,7 @@ internal static async Task InstallKindAndHelmAsync( await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90)); // Download kubectl if not already installed - await auto.TypeAsync($"command -v kubectl >/dev/null 2>&1 || {{ for i in 1 2 3; do curl -sSLo ~/.local/bin/kubectl \"https://dl.k8s.io/release/{KubectlVersion}/bin/linux/amd64/kubectl\" && file ~/.local/bin/kubectl | grep -q ELF && break; echo \"Retry $i: kubectl download failed, retrying in 5s...\"; sleep 5; done && chmod +x ~/.local/bin/kubectl; }}"); + await auto.TypeAsync($"command -v kubectl >/dev/null 2>&1 || {{ rm -f ~/.local/bin/kubectl; for i in 1 2 3; do curl -sSLo ~/.local/bin/kubectl \"https://dl.k8s.io/release/{KubectlVersion}/bin/linux/amd64/kubectl\" && chmod +x ~/.local/bin/kubectl && ~/.local/bin/kubectl version --client >/dev/null 2>&1 && break; echo \"Retry $i: kubectl download failed, retrying in 5s...\"; rm -f ~/.local/bin/kubectl; sleep 5; done; test -x ~/.local/bin/kubectl && ~/.local/bin/kubectl version --client >/dev/null 2>&1; }}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90)); @@ -89,6 +89,13 @@ internal static async Task CreateKindClusterWithRegistryAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); + // The cluster is created by the host Docker daemon, so the default kubeconfig points kubectl at a + // localhost-published API server port that is not reachable from inside the helper container. Join the + // helper container to the kind network and switch kubectl to the cluster's internal control-plane endpoint. + await auto.TypeAsync($"docker network connect \"kind\" \"$(hostname)\" 2>/dev/null || true && kind export kubeconfig --name={clusterName} --internal >/dev/null"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + // Configure containerd on each node to resolve localhost:5001 via the registry container. // This uses the config_path approach required by containerd v2+ (shipped in KinD v0.31.0+). await auto.TypeAsync($"for node in $(kind get nodes --name={clusterName}); do " + @@ -272,10 +279,7 @@ await auto.WaitUntilAsync( { await auto.TypeAsync($"aspire add {package}"); await auto.EnterAsync(); - // aspire add shows a version selection prompt — accept the first (latest) version - await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromSeconds(180)); } // Step 4: Add client NuGet packages to ApiService (--prerelease needed for PR builds) @@ -451,4 +455,3 @@ internal static async Task CleanupKindClusterOutOfBandAsync(string clusterName, } } } - diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs index 033ec31a64d..43b0d6f9c3b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs @@ -18,10 +18,10 @@ public sealed class JavaCodegenValidationTests(ITestOutputHelper output) public async Task RestoreGeneratesSdkFiles() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task RestoreGeneratesSdkFiles() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.EnableExperimentalJavaSupportAsync(counter); await auto.TypeAsync("aspire init"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs index 6e841e0817a..8b5fb4b5aac 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs @@ -16,13 +16,14 @@ namespace Aspire.Cli.EndToEnd.Tests; public sealed class JavaEmptyAppHostTemplateTests(ITestOutputHelper output) { [Fact] + [CaptureWorkspaceOnFailure] public async Task CreateAndRunJavaEmptyAppHostProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +31,7 @@ public async Task CreateAndRunJavaEmptyAppHostProject() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.EnableExperimentalJavaSupportAsync(counter); await auto.AspireNewAsync("JavaEmptyApp", counter, template: AspireTemplate.JavaEmptyAppHost); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs index 6742deae355..61371ecdf31 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs @@ -18,10 +18,10 @@ public sealed class JavaPolyglotTests(ITestOutputHelper output) public async Task CreateJavaAppHostWithViteApp() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task CreateJavaAppHostWithViteApp() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.EnableExperimentalJavaSupportAsync(counter); await auto.TypeAsync("aspire init"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs index 693524792b9..dabc1e129ad 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs @@ -26,25 +26,18 @@ public sealed class JavaScriptPublishTests(ITestOutputHelper output) [QuarantinedTest("https://github.com/microsoft/aspire/issues/16188")] public async Task AllPublishMethodsBuildDockerImages() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); - - if (isCI) - { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); - } + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); // Create TS AppHost and add packages await auto.TypeAsync("aspire init"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs index b8d6153b15c..3cd4da46316 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs @@ -16,13 +16,14 @@ namespace Aspire.Cli.EndToEnd.Tests; public sealed class JsReactTemplateTests(ITestOutputHelper output) { [Fact] + [CaptureWorkspaceOnFailure] public async Task CreateAndRunJsReactProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +31,7 @@ public async Task CreateAndRunJsReactProject() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.AspireNewAsync("AspireJsReactApp", counter, template: AspireTemplate.JsReact, useRedisCache: false); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs index 94518d75dec..720d52675e0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs @@ -19,30 +19,29 @@ public sealed class KubernetesDeployBasicApiServiceTests(ITestOutputHelper outpu [CaptureWorkspaceOnFailure] public async Task DeployK8sBasicApiService() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); // Prepare environment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs index b0c61e76aea..353e671b7ae 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs @@ -20,30 +20,28 @@ public sealed class KubernetesDeployTypeScriptTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployTypeScriptAppToKubernetes() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - // Prepare environment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } @@ -71,8 +69,7 @@ public async Task DeployTypeScriptAppToKubernetes() // Add Kubernetes hosting package await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2)); // Regenerate TypeScript SDK with Kubernetes types await auto.TypeAsync("aspire restore"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs index da99dad6c8f..75de6423f83 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithGarnetTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithGarnet() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs index 74fc388f43c..39e27de21d9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithMongoDBTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithMongoDB() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs index ea4cf176ab1..70633ede0a7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithMySqlTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithMySql() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs index 53c83cf4714..9f15cd11a2c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs @@ -21,29 +21,28 @@ public sealed class KubernetesDeployWithNatsTests(ITestOutputHelper output) [QuarantinedTest("https://github.com/microsoft/aspire/issues/15789")] public async Task DeployK8sWithNats() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs index 1a69048d437..7c46659c2c1 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithPostgresTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithPostgres() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs index 4e99408c5a1..ec306e5b841 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithRabbitMQTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithRabbitMQ() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs index aceb6b841d6..a486d52a384 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithRedisTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithRedis() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs index 9db1fd1e4bc..3aae3ced57d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithSqlServerTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithSqlServer() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs index abdeb0ade1e..472ae40fa43 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs @@ -19,29 +19,28 @@ public sealed class KubernetesDeployWithValkeyTests(ITestOutputHelper output) [CaptureWorkspaceOnFailure] public async Task DeployK8sWithValkey() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName(); var k8sNamespace = $"test-{clusterName[..16]}"; output.WriteLine($"Cluster name: {clusterName}"); output.WriteLine($"Namespace: {k8sNamespace}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 6e81667b57e..e996343d602 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -31,31 +31,29 @@ private static string GenerateUniqueClusterName() => [QuarantinedTest("https://github.com/microsoft/aspire/issues/15870")] public async Task CreateAndPublishToKubernetes() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = GenerateUniqueClusterName(); output.WriteLine($"Using KinD version: {KindVersion}"); output.WriteLine($"Using Helm version: {HelmVersion}"); output.WriteLine($"Using cluster name: {clusterName}"); - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - // Prepare environment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } @@ -133,12 +131,9 @@ public async Task CreateAndPublishToKubernetes() // 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)); + await auto.WaitForAspireAddSuccessAsync(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 diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs index ffbf5300d36..a6e0c46c6d0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs @@ -39,11 +39,11 @@ public sealed class LocalConfigMigrationTests(ITestOutputHelper output) public async Task LegacySettingsMigration_AdjustsRelativeAppHostPath() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( - repoRoot, installMode, output, + repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); @@ -54,7 +54,7 @@ public async Task LegacySettingsMigration_AdjustsRelativeAppHostPath() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Create a valid TypeScript AppHost using aspire init. // This produces apphost.ts, .modules/, aspire.config.json, etc. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs index 929fd851248..17ba9be7d16 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs @@ -19,11 +19,11 @@ public sealed class LogsCommandTests(ITestOutputHelper output) public async Task LogsCommandShowsResourceLogs() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -32,7 +32,7 @@ public async Task LogsCommandShowsResourceLogs() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("AspireLogsTestApp", counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs index dcb5a45ad41..9b521a22852 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs @@ -18,11 +18,11 @@ public sealed class MultipleAppHostTests(ITestOutputHelper output) public async Task DetachFormatJsonProducesValidJson() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -31,7 +31,7 @@ public async Task DetachFormatJsonProducesValidJson() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a single project using aspire new await auto.AspireNewAsync("TestApp", counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs index 5210346766f..cbd7b80ee45 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs @@ -33,10 +33,10 @@ public sealed class NewWithAgentInitTests(ITestOutputHelper output) public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -44,7 +44,7 @@ public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create .claude folder so agent init detects a Claude Code environment. // This needs to exist in the workspace root before aspire new creates the project @@ -110,11 +110,6 @@ await auto.WaitUntilAsync( await auto.TypeAsync("y"); await auto.EnterAsync(); - // Agent init: workspace path - accept default - await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitAsync(500); - await auto.EnterAsync(); - // Agent init: skill location - select Claude Code await auto.WaitUntilAsync( s => s.ContainsText("skill files be installed"), diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs index 7915b17a953..258f27d5eb7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs @@ -29,10 +29,10 @@ public sealed class PlaywrightCliInstallTests(ITestOutputHelper output) public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -41,7 +41,7 @@ public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Verify playwright-cli is not installed. await auto.TypeAsync("playwright-cli --version 2>&1 || true"); @@ -115,10 +115,10 @@ await auto.WaitUntilAsync( public async Task AgentInit_WhenCwdDiffersFromWorkspaceRoot_PlacesSkillFilesInWorkspaceRoot() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -127,7 +127,7 @@ public async Task AgentInit_WhenCwdDiffersFromWorkspaceRoot_PlacesSkillFilesInWo await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Create an Aspire project. await auto.AspireNewAsync("TestProject", counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs index d88c7b147ae..5609e000e84 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -12,40 +12,37 @@ namespace Aspire.Cli.EndToEnd.Tests; /// /// End-to-end tests for Aspire CLI deployment to Docker Compose using Podman as the container runtime. /// Validates that setting ASPIRE_CONTAINER_RUNTIME=podman flows through to compose operations. -/// Requires Podman and docker-compose v2 installed on the host. +/// Runs Podman inside a privileged Docker helper container so the test does not depend on host Podman state. /// public sealed class PodmanDeploymentTests(ITestOutputHelper output) { private const string ProjectName = "AspirePodmanDeployTest"; [Fact] - [ActiveIssue("https://github.com/mitchdenny/hex1b/pull/270")] - [OuterloopTest("Requires Podman and docker-compose v2 installed on the host")] + [OuterloopTest("Requires Docker to run a privileged Podman helper container")] public async Task CreateAndDeployToDockerComposeWithPodman() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var strategy = CliInstallStrategy.Detect(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + using var terminal = CliE2ETestHelpers.CreatePodmanDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - // PrepareEnvironment - await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); - if (isCI) + if (strategy.Mode == CliInstallMode.PullRequest) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireCliEnvironmentAsync(counter); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } - // Step 0: Verify Podman is available, skip if not + // Step 0: Verify Podman is available inside the helper container. await auto.TypeAsync("podman --version || echo 'PODMAN_NOT_FOUND'"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); @@ -67,13 +64,7 @@ public async Task CreateAndDeployToDockerComposeWithPodman() await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); await auto.EnterAsync(); - if (isCI) - { - await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); - } - - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromSeconds(180)); // Step 5: Modify AppHost's main file to add Docker Compose environment { @@ -119,8 +110,8 @@ public async Task CreateAndDeployToDockerComposeWithPodman() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Step 10: Verify the application is accessible - await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(podman 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'"); + // Step 10: Verify the application is accessible inside the helper container's network namespace. + await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$(podman ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '[0-9]+->8080' | head -1 | cut -d- -f1) 2>/dev/null || echo 'request-failed'"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index d5e6698291d..aa67796a98d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -24,10 +24,10 @@ public sealed class ProjectReferenceTests(ITestOutputHelper output) public async Task TypeScriptAppHostWithProjectReferenceIntegration() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -36,7 +36,7 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Create a TypeScript AppHost (so we get the SDK version in aspire.config.json) await auto.TypeAsync("aspire init --language typescript --non-interactive"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index 1860ecf2c9b..2efbdfa1081 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -19,11 +19,11 @@ public sealed class PsCommandTests(ITestOutputHelper output) public async Task PsCommandListsRunningAppHost() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -32,7 +32,7 @@ public async Task PsCommandListsRunningAppHost() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("AspirePsTestApp", counter); @@ -89,11 +89,11 @@ public async Task PsCommandListsRunningAppHost() public async Task PsFormatJsonOutputsOnlyJsonToStdout() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -102,7 +102,7 @@ public async Task PsFormatJsonOutputsOnlyJsonToStdout() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); var outputFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "ps-output.json"); var containerOutputFilePath = CliE2ETestHelpers.ToContainerPath(outputFilePath, workspace); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs index 9b80af4288f..789fa78a9e6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs @@ -19,10 +19,10 @@ public sealed class PythonReactTemplateTests(ITestOutputHelper output) public async Task CreateAndRunPythonReactProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +30,7 @@ public async Task CreateAndRunPythonReactProject() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Create project using aspire new, selecting the FastAPI/React template await auto.AspireNewAsync("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs index 13d43374a77..57d3e713f13 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs @@ -17,10 +17,10 @@ public sealed class SecretDotNetAppHostTests(ITestOutputHelper output) public async Task SecretCrudOnDotNetAppHost() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task SecretCrudOnDotNetAppHost() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create an Empty AppHost project interactively await auto.AspireNewAsync("TestSecrets", counter, template: AspireTemplate.EmptyAppHost); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs index e1645b0be32..a0c73afb4f5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs @@ -17,10 +17,10 @@ public sealed class SecretTypeScriptAppHostTests(ITestOutputHelper output) public async Task SecretCrudOnTypeScriptAppHost() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task SecretCrudOnTypeScriptAppHost() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create TypeScript AppHost via aspire init await auto.TypeAsync("aspire init"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index b3e28d4bf1d..154b7b4813c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Cli.EndToEnd.Tests; /// public sealed class SmokeTests(ITestOutputHelper output) { + [CaptureWorkspaceOnFailure] [Fact] public async Task CreateAndRunAspireStarterProject() { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index e0b6130740e..8ee7a9787e9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -19,10 +19,10 @@ public sealed class StagingChannelTests(ITestOutputHelper output) public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -31,7 +31,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Configure staging channel settings via aspire config set // Note: we do NOT need to enable features.stagingChannelEnabled — setting channel diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index e86b997dc15..56707003a19 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -56,13 +56,13 @@ public async Task CreateStartAndStopAspireProject() // Stop the AppHost using aspire stop await auto.TypeAsync("aspire stop"); await auto.EnterAsync(); + await auto.WaitUntilTextAsync(StopCommandStrings.AppHostStoppedSuccessfully, timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); await auto.ClearScreenAsync(counter); // Docker network cleanup can lag behind aspire stop on contended CI runners. await auto.ExecuteCommandUntilOutputAsync(counter, $"docker network ls --format json | grep -i -- '{projectName}' | wc -l", "0", timeout: TimeSpan.FromMinutes(5)); - // Exit the shell await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs index 83948df1ccc..c044f5b2889 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs @@ -20,11 +20,11 @@ public sealed class StopNonInteractiveTests(ITestOutputHelper output) public async Task StopNonInteractiveSingleAppHost() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -32,7 +32,7 @@ public async Task StopNonInteractiveSingleAppHost() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("TestStopApp", counter); @@ -77,11 +77,11 @@ public async Task StopNonInteractiveSingleAppHost() public async Task StopAllAppHostsFromAppHostDirectory() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -89,7 +89,7 @@ public async Task StopAllAppHostsFromAppHostDirectory() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create first project await auto.AspireNewAsync("App1", counter); @@ -144,11 +144,11 @@ public async Task StopAllAppHostsFromAppHostDirectory() public async Task StopAllAppHostsFromUnrelatedDirectory() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -156,7 +156,7 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create first project await auto.AspireNewAsync("App1", counter); @@ -216,11 +216,11 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() public async Task StopNonInteractiveMultipleAppHostsShowsError() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -228,7 +228,7 @@ public async Task StopNonInteractiveMultipleAppHostsShowsError() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create first project await auto.AspireNewAsync("App1", counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs index 3273d2b852b..3d7dac01998 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs @@ -16,13 +16,14 @@ namespace Aspire.Cli.EndToEnd.Tests; public sealed class TypeScriptEmptyAppHostTemplateTests(ITestOutputHelper output) { [Fact] + [CaptureWorkspaceOnFailure] public async Task CreateAndRunTypeScriptEmptyAppHostProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +31,7 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 17fb45ae9ba..7bf9a2ddaf6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -20,10 +20,10 @@ public sealed class TypeScriptPolyglotTests(ITestOutputHelper output) public async Task CreateTypeScriptAppHostWithViteApp() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -32,7 +32,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Step 1: Create TypeScript AppHost using aspire init with interactive language selection await auto.TypeAsync("aspire init"); @@ -102,13 +102,10 @@ public async Task CreateTypeScriptAppHostWithViteApp() public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, workspace, installMode, - ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]); - var channelArgument = localChannel is not null ? " --channel local" : string.Empty; - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -122,12 +119,10 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.EnablePolyglotSupportAsync(counter); - await auto.MountLocalChannelPackagesAsync(localChannel, workspace, counter); - // Create brownfield Vite project await auto.TypeAsync("mkdir brownfield && cd brownfield"); await auto.EnterAsync(); @@ -146,13 +141,8 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot() originalPreviewScript = scripts["preview"]?.GetValue(); originalTsConfig = File.ReadAllText(Path.Combine(projectRoot, "tsconfig.json")); - if (localChannel is not null) - { - CliE2ETestHelpers.WriteLocalChannelSettings(projectRoot, localChannel.SdkVersion); - } - // Run aspire init in brownfield mode - await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}"); + await auto.TypeAsync("aspire init --language typescript --non-interactive"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs index d77a60c3aa3..25eff0ade88 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs @@ -18,10 +18,10 @@ public sealed class TypeScriptPublishTests(ITestOutputHelper output) public async Task PublishWithDockerComposeServiceCallbackSucceeds() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); using var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -30,7 +30,7 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds() await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.EnablePolyglotSupportAsync(counter); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs index 779d73bc7cc..c03ca183dd4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs @@ -18,10 +18,10 @@ public sealed class TypeScriptReusablePackageTests(ITestOutputHelper output) public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -29,7 +29,7 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); var appDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "app")); var helperDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "packages", "aspire-commands")); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs index 3e7604a0f98..c1d764f44e0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs @@ -16,21 +16,16 @@ namespace Aspire.Cli.EndToEnd.Tests; public sealed class TypeScriptSqlServerNativeAssetsBundleTests(ITestOutputHelper output) { [Fact] + [CaptureWorkspaceOnFailure] public async Task StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - var localChannel = CliE2ETestHelpers.PrepareLocalChannel( - repoRoot, - workspace, - installMode, - ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.SqlServer."]); - var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( repoRoot, - installMode, + strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, @@ -42,15 +37,9 @@ public async Task StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets() var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); - await auto.MountLocalChannelPackagesAsync(localChannel, workspace, counter); - - if (localChannel is not null) - { - CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion); - } + await auto.InstallAspireCliAsync(strategy, counter); - await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}"); + 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); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs index d81f97caa91..223f39b264b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs @@ -15,6 +15,7 @@ namespace Aspire.Cli.EndToEnd.Tests; /// public sealed class TypeScriptStarterTemplateTests(ITestOutputHelper output) { + [CaptureWorkspaceOnFailure] [Fact] public async Task CreateAndRunTypeScriptStarterProject() { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs index 5b0ad9be9be..ee863b43595 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -21,11 +21,11 @@ public sealed class WaitCommandTests(ITestOutputHelper output) public async Task CreateStartWaitAndStopAspireProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var strategy = CliInstallStrategy.Detect(); var workspace = TemporaryWorkspace.Create(output); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -36,7 +36,7 @@ public async Task CreateStartWaitAndStopAspireProject() await auto.PrepareDockerEnvironmentAsync(counter, workspace); // Install the Aspire CLI - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); // Create a new project using aspire new await auto.AspireNewAsync("AspireWaitApp", counter); diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index 8049ecb658f..6b19d648a4f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -1376,6 +1376,64 @@ public async Task SearchPackagesAsyncSucceedsOnFirstAttemptWithoutRetry() Assert.Equal(1, executor.AttemptCount); // Should have attempted only once } + [Fact] + public async Task InstallTemplateAsync_UsesLocalPackagePathForLocalFolderSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var packageVersion = "13.3.0-local.1"; + var packagesDirectory = workspace.WorkspaceRoot.CreateSubdirectory("packages"); + var packagePath = Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{packageVersion}.nupkg"); + await File.WriteAllTextAsync(packagePath, string.Empty); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var invocationCount = 0; + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, _, workingDirectory, _) => + { + invocationCount++; + Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory.FullName); + Assert.Equal("new", args[0]); + + switch (invocationCount) + { + case 1: + Assert.Equal("uninstall", args[1]); + Assert.Equal("Aspire.ProjectTemplates", args[2]); + Assert.DoesNotContain("--force", args); + Assert.DoesNotContain("--nuget-source", args); + break; + case 2: + Assert.Equal("install", args[1]); + Assert.Equal(packagePath, args[2]); + Assert.DoesNotContain("--force", args); + Assert.DoesNotContain("--nuget-source", args); + break; + default: + Assert.Fail($"Unexpected dotnet invocation {invocationCount}."); + break; + } + }, + 0); + + var result = await runner.InstallTemplateAsync( + "Aspire.ProjectTemplates", + packageVersion, + nugetConfigFile: null, + nugetSource: "packages", + force: true, + new ProcessInvocationOptions(), + CancellationToken.None); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(packageVersion, result.TemplateVersion); + Assert.Equal(2, invocationCount); + } + [Theory] [InlineData("Success: Aspire.ProjectTemplates@13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // New .NET 10.0 SDK format with @ separator [InlineData("Success: Aspire.ProjectTemplates::13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // Old SDK format with :: separator diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs new file mode 100644 index 00000000000..930f89e755c --- /dev/null +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -0,0 +1,236 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Aspire.Cli.DotNet; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.DotNet; + +public sealed class ProcessExecutionTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var outputFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "output.json")); + await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400)); + + var scriptFile = await CreateOutputScriptAsync(workspace.WorkspaceRoot, outputFile); + var startInfo = CreateStartInfo(scriptFile); + var process = new Process + { + StartInfo = startInfo + }; + + var stdoutBuilder = new StringBuilder(); + var stderrBuilder = new StringBuilder(); + using var execution = new ProcessExecution( + process, + NullLogger.Instance, + new ProcessInvocationOptions + { + StandardOutputCallback = line => + { + stdoutBuilder.AppendLine(line); + Thread.Sleep(5); + }, + StandardErrorCallback = line => stderrBuilder.AppendLine(line) + }); + + Assert.True(execution.Start()); + + var exitCode = await execution.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(0, exitCode); + Assert.True(string.IsNullOrWhiteSpace(stderrBuilder.ToString())); + + using var jsonDocument = JsonDocument.Parse(stdoutBuilder.ToString()); + var values = jsonDocument.RootElement.GetProperty("values"); + Assert.Equal(400, values.GetArrayLength()); + Assert.Equal("value-399", values[399].GetString()); + } + + [Fact] + public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var outputFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "output.json")); + await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400)); + + var scriptFile = await CreateDelayedOutputScriptAsync(workspace.WorkspaceRoot, outputFile); + var startInfo = CreateStartInfo(scriptFile); + var process = new Process + { + StartInfo = startInfo + }; + + var stdoutBuilder = new StringBuilder(); + var stderrBuilder = new StringBuilder(); + using var firstLineSeen = new ManualResetEventSlim(); + using var releaseCallback = new ManualResetEventSlim(); + + var releaseTask = Task.Run(async () => + { + if (!firstLineSeen.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Timed out waiting for the initial stdout marker."); + } + + await Task.Delay(TimeSpan.FromMilliseconds(6500)); + releaseCallback.Set(); + }); + + using var execution = new ProcessExecution( + process, + NullLogger.Instance, + new ProcessInvocationOptions + { + StandardOutputCallback = line => + { + stdoutBuilder.AppendLine(line); + + if (line == "ready") + { + firstLineSeen.Set(); + + if (!releaseCallback.Wait(TimeSpan.FromSeconds(20))) + { + throw new TimeoutException("Timed out waiting to release the blocked stdout callback."); + } + } + }, + StandardErrorCallback = line => stderrBuilder.AppendLine(line) + }); + + Assert.True(execution.Start()); + + var exitCode = await execution.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(30)); + await releaseTask.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.Equal(0, exitCode); + Assert.True(string.IsNullOrWhiteSpace(stderrBuilder.ToString())); + + var stdout = stdoutBuilder.ToString(); + var jsonStart = stdout.IndexOf('{', StringComparison.Ordinal); + Assert.True(jsonStart >= 0, stdout); + + using var jsonDocument = JsonDocument.Parse(stdout[jsonStart..]); + var values = jsonDocument.RootElement.GetProperty("values"); + Assert.Equal(400, values.GetArrayLength()); + Assert.Equal("value-399", values[399].GetString()); + } + + private static string CreateJsonPayload(int lineCount) + { + var builder = new StringBuilder(); + builder.AppendLine("{"); + builder.AppendLine(" \"values\": ["); + + for (var i = 0; i < lineCount; i++) + { + var suffix = i == lineCount - 1 ? string.Empty : ","; + builder.AppendLine($" \"value-{i}\"{suffix}"); + } + + builder.AppendLine(" ]"); + builder.AppendLine("}"); + return builder.ToString(); + } + + private static async Task CreateOutputScriptAsync(DirectoryInfo workspaceRoot, FileInfo outputFile) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-output.cmd")); + var content = + "@echo off" + Environment.NewLine + + $"type \"{outputFile.FullName}\"" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + return scriptFile; + } + else + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-output.sh")); + var content = + "#!/usr/bin/env bash" + Environment.NewLine + + $"cat \"{outputFile.FullName}\"" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + + File.SetUnixFileMode( + scriptFile.FullName, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + return scriptFile; + } + } + + private static async Task CreateDelayedOutputScriptAsync(DirectoryInfo workspaceRoot, FileInfo outputFile) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-delayed-output.cmd")); + var content = + "@echo off" + Environment.NewLine + + "echo ready" + Environment.NewLine + + "powershell -NoProfile -Command \"Start-Sleep -Seconds 6\"" + Environment.NewLine + + $"type \"{outputFile.FullName}\"" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + return scriptFile; + } + else + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-delayed-output.sh")); + var content = + "#!/usr/bin/env bash" + Environment.NewLine + + "echo ready" + Environment.NewLine + + "sleep 6" + Environment.NewLine + + $"cat \"{outputFile.FullName}\"" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + + File.SetUnixFileMode( + scriptFile.FullName, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + return scriptFile; + } + } + + private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + WorkingDirectory = scriptFile.Directory!.FullName, + ArgumentList = { "/d", "/c", scriptFile.FullName } + }; + } + + return new ProcessStartInfo("/bin/bash") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + WorkingDirectory = scriptFile.Directory!.FullName, + ArgumentList = { scriptFile.FullName } + }; + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 0a88f849e80..3908db70b50 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -718,6 +718,66 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed Assert.Null(stagingChannel.PinnedVersion); } + [Fact] + public async Task GetChannelsAsync_WhenLocalHiveContainsProjectTemplatesPackage_ChannelHasPinnedVersion() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages")); + localPackagesDir.Create(); + + const string localVersion = "13.3.0-local.20260413.t002308"; + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{localVersion}.nupkg"), string.Empty); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build()); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var localChannel = channels.First(c => c.Name == "local"); + Assert.Equal(localVersion, localChannel.PinnedVersion); + } + + [Fact] + public async Task LocalHiveChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() + { + // Arrange - simulate package search returning a mismatched stable version + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.2", Source = "https://api.nuget.org/v3/index.json" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages")); + localPackagesDir.Create(); + + const string localVersion = "13.3.0-local.20260413.t002308"; + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty); + + var packagingService = new PackagingService(executionContext, fakeCache, new TestFeatures(), new ConfigurationBuilder().Build()); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var localChannel = channels.First(c => c.Name == "local"); + var templatePackages = await localChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var package = Assert.Single(templatePackages); + Assert.Equal("Aspire.ProjectTemplates", package.Id); + Assert.Equal(localVersion, package.Version); + Assert.Equal(localPackagesDir.FullName.Replace('\\', '/'), package.Source); + } + /// /// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result /// with the pinned version, bypassing actual NuGet search. diff --git a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs index 87c2fcc5dca..7cf0f6a00a0 100644 --- a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs @@ -1,6 +1,8 @@ // 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.Telemetry; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.Utils; @@ -360,4 +362,29 @@ public void CleanupOrphanedSockets_CleansUpBothOldAndNewFormatSockets() Assert.False(File.Exists(orphanedSocket), "Orphaned socket should be deleted"); Assert.True(File.Exists(liveSocket), "Live socket should still exist"); } + [Theory] + [InlineData("10.0.0", true)] + [InlineData("9.2.0", true)] + [InlineData("9.3.0", true)] + [InlineData("13.0.0-preview.1", true)] + [InlineData("9.1.0", false)] + [InlineData("8.0.0", false)] + [InlineData("1.0.0", false)] + public async Task CheckAppHostCompatibility_VersionCheck(string aspireVersion, bool expectedCompatible) + { + var runner = new TestDotNetCliRunner + { + GetAppHostInformationAsyncCallback = (_, _, _) => (0, true, aspireVersion) + }; + var interactionService = new TestInteractionService(); + var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); + var projectFile = new FileInfo(Path.Combine(Path.GetTempPath(), "test.csproj")); + var workingDirectory = new DirectoryInfo(Path.GetTempPath()); + + var (isCompatible, _, returnedVersion) = await AppHostHelper.CheckAppHostCompatibilityAsync( + runner, interactionService, projectFile, telemetry, workingDirectory, "test.log", CancellationToken.None); + + Assert.Equal(expectedCompatible, isCompatible); + Assert.Equal(aspireVersion, returnedVersion); + } } diff --git a/tests/Shared/Docker/Dockerfile.e2e b/tests/Shared/Docker/Dockerfile.e2e index 9b7d076caeb..c6169e88407 100644 --- a/tests/Shared/Docker/Dockerfile.e2e +++ b/tests/Shared/Docker/Dockerfile.e2e @@ -50,8 +50,9 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime # --- Common tooling (shared between dotnet and polyglot variants) --- # Install gh CLI (needed by get-aspire-cli-pr.sh to download PR artifacts). +# Install Docker CLI plus the buildx/compose plugins used by publish and deploy flows. RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends gpg docker.io && \ + apt-get install -y --no-install-recommends gpg docker.io docker-buildx docker-compose-v2 && \ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ diff --git a/tests/Shared/Docker/Dockerfile.e2e-podman b/tests/Shared/Docker/Dockerfile.e2e-podman new file mode 100644 index 00000000000..44b458ffb31 --- /dev/null +++ b/tests/Shared/Docker/Dockerfile.e2e-podman @@ -0,0 +1,53 @@ +# Container image for CLI E2E Podman tests. +# +# Runs a nested Podman runtime inside a privileged Docker container so Podman-specific +# deploy flows can be tested without depending on host Podman state. +FROM mcr.microsoft.com/dotnet/sdk:10.0 + +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + gpg \ + podman \ + podman-compose \ + fuse-overlayfs \ + slirp4netns \ + uidmap \ + containernetworking-plugins && \ + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update -qq && \ + apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +# Rootful Podman-in-container is reliable here when it uses a dedicated vfs-backed storage root. +# Apply the flags centrally so the CLI and podman-compose can invoke plain `podman`. +RUN cat > /usr/local/bin/podman <<'SCRIPT' +#!/bin/bash +common=(--root /var/lib/containers/storage-vfs --runroot /run/containers/storage-vfs --storage-driver=vfs --cgroup-manager=cgroupfs) +if [ "$#" -gt 0 ] && { [ "$1" = "run" ] || [ "$1" = "create" ]; }; then + cmd="$1" + shift + exec /usr/bin/podman "${common[@]}" "$cmd" --cgroups=disabled "$@" +else + exec /usr/bin/podman "${common[@]}" "$@" +fi +SCRIPT +RUN chmod +x /usr/local/bin/podman + +COPY eng/scripts/get-aspire-cli.sh /opt/aspire-scripts/ +COPY eng/scripts/get-aspire-cli-pr.sh /opt/aspire-scripts/ +RUN chmod +x /opt/aspire-scripts/*.sh + +RUN mkdir -p /workspace +WORKDIR /workspace + +ENV PATH="/root/.aspire/bin:/root/.aspire:/usr/local/bin:${PATH}" +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +ENV DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +ENV DOTNET_GENERATE_ASPNET_CERTIFICATE=false +ENV ASPIRE_PLAYGROUND=true +ENV TERM=xterm diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs index 16274a5a8c0..bded6082f6f 100644 --- a/tests/Shared/Hex1bTestHelpers.cs +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -79,7 +79,7 @@ internal static class Hex1bTestHelpers /// Uses default dimensions of 160x48 unless overridden. /// /// The test name used for the recording file path. Defaults to the calling method name. - /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The subdirectory name under the local TestResults/recordings directory. /// The terminal width in columns. Defaults to 160. /// The terminal height in rows. Defaults to 48. /// A configured instance. Caller is responsible for disposal. @@ -103,10 +103,10 @@ internal static Hex1bTerminal CreateTestTerminal( /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// In CI, this returns a path under $GITHUB_WORKSPACE/testresults/recordings/. - /// Locally, this returns a path under the system temp directory. + /// Locally, this returns a path under the test output TestResults/recordings/ directory. /// /// The name of the test (used as the recording filename). - /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The subdirectory name under the local TestResults/recordings directory. /// The full path to the .cast recording file. internal static string GetTestResultsRecordingPath(string testName, string localSubDir) { @@ -120,8 +120,8 @@ internal static string GetTestResultsRecordingPath(string testName, string local } else { - // Local development - use temp directory - recordingsDir = Path.Combine(Path.GetTempPath(), localSubDir, "recordings"); + // Local development - keep recordings with the rest of the test output. + recordingsDir = Path.Combine(AppContext.BaseDirectory, "TestResults", "recordings", localSubDir); } Directory.CreateDirectory(recordingsDir); diff --git a/tests/Shared/TemporaryRepo.cs b/tests/Shared/TemporaryRepo.cs index b68046d7a4e..b5776a28943 100644 --- a/tests/Shared/TemporaryRepo.cs +++ b/tests/Shared/TemporaryRepo.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Diagnostics; using Xunit; @@ -8,6 +9,8 @@ namespace Aspire.Cli.Tests.Utils; internal sealed class TemporaryWorkspace(ITestOutputHelper outputHelper, DirectoryInfo repoDirectory) : IDisposable { + private static readonly ConcurrentDictionary s_preservedWorkspaces = new(StringComparer.Ordinal); + public DirectoryInfo WorkspaceRoot => repoDirectory; public DirectoryInfo CreateDirectory(string name) @@ -45,6 +48,12 @@ public async Task InitializeGitAsync(CancellationToken cancellationToken = defau public void Dispose() { + if (s_preservedWorkspaces.TryRemove(repoDirectory.FullName, out _)) + { + outputHelper.WriteLine($"Preserved temporary workspace at: {repoDirectory.FullName}"); + return; + } + try { repoDirectory.Delete(true); @@ -55,6 +64,17 @@ public void Dispose() } } + internal void Preserve() + { + s_preservedWorkspaces[repoDirectory.FullName] = 0; + outputHelper.WriteLine($"Marked temporary workspace for preservation: {repoDirectory.FullName}"); + } + + internal static void ReleasePreservation(string workspacePath) + { + s_preservedWorkspaces.TryRemove(workspacePath, out _); + } + internal static TemporaryWorkspace Create(ITestOutputHelper outputHelper) { var tempPath = Path.GetTempPath();