diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index 6e45aa8e87b..ec32bc2c80a 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -596,3 +596,207 @@ RUN_ID=$(gh run list --branch $(git branch --show-current) --workflow CI --limit | Pattern not found but text is visible | Using `FindPattern` with regex special chars | Use `Find()` instead of `FindPattern()` for literal strings containing `(`, `)`, `/`, etc. | | Test hangs indefinitely | Waiting for wrong prompt number | Verify `SequenceCounter` usage matches commands | | Timeout waiting for dashboard URL | Project failed to build/run | Check recording for build errors | + +## Sample Upgrade Tests + +Sample upgrade tests validate that `aspire update` can upgrade external Git repos (e.g., `dotnet/aspire-samples`) to the PR/CI build and that the upgraded samples run correctly. + +**Location**: `tests/Aspire.Cli.EndToEnd.Tests/SampleUpgrade*.cs` and `Helpers/SampleUpgradeHelpers.cs` + +### Test Architecture + +Each sample upgrade test follows this flow: + +1. Create a Docker terminal with host networking and Docker socket access +2. Install the Aspire CLI from the PR build +3. Clone the external repo (e.g., `dotnet/aspire-samples`) +4. Run `aspire update --channel pr-{N}` to upgrade packages to the PR version +5. Verify the upgrade via mounted volume (read csproj, check versions) +6. Run `aspire run` and capture the dashboard URL +7. Verify the dashboard shows expected resources via Playwright +8. Poll HTTP endpoints from the host to confirm services are running +9. Take a dashboard screenshot and save it as a test artifact +10. Stop the apphost and exit + +### Helper Classes + +| Class | Description | +|-------|-------------| +| `SampleUpgradeHelpers` | Extension methods on `Hex1bTerminalAutomator` for clone, update, run, stop | +| `DashboardVerificationHelpers` | Playwright-based dashboard verification and HTTP endpoint polling | +| `AspireRunInfo` | Record returned by `AspireRunSampleAsync` containing the dashboard URL | + +### Host Network Mode + +Sample upgrade tests use Docker's host network mode (`c.Network = "host"`) so that services started by `aspire run` inside the container are directly accessible from the test process on the host. This enables: +- Playwright browser access to the Aspire dashboard +- `HttpClient` polling of app HTTP endpoints +- No port mapping configuration needed + +```csharp +using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, installMode, output, + mountDockerSocket: true, + useHostNetwork: true, + workspace: workspace, + additionalVolumes: [$"{workDir}:{containerWorkDir}"]); +``` + +### Dashboard URL Capture + +`AspireRunSampleAsync` returns an `AspireRunInfo` record containing the dashboard login URL parsed from terminal output. The URL includes the auth token needed for Playwright access. + +```csharp +var runInfo = await auto.AspireRunSampleAsync( + appHostRelativePath: AppHostCsproj, + startTimeout: TimeSpan.FromMinutes(5)); + +// runInfo.DashboardUrl is e.g.: http://localhost:18888/login?t=abc123 +``` + +### Playwright Dashboard Verification + +Use `DashboardVerificationHelpers.VerifyDashboardAsync` to navigate to the dashboard, verify resources are displayed, and take a screenshot: + +```csharp +var screenshotPath = DashboardVerificationHelpers.GetScreenshotPath( + nameof(UpgradeAndRunAspireWithNodeSample)); + +await DashboardVerificationHelpers.VerifyDashboardAsync( + runInfo.DashboardUrl, + expectedResourceNames: ["cache", "weatherapi", "frontend"], + screenshotPath, + output, + timeout: TimeSpan.FromSeconds(90)); +``` + +Playwright browsers are installed automatically on first use via `EnsureBrowsersInstalled()`. This installs Chromium with system dependencies. + +Screenshots are saved to `testresults/screenshots/` within the test output directory and are included in CI artifacts. + +### HTTP Endpoint Polling from Host + +Use `DashboardVerificationHelpers.PollEndpointAsync` to verify HTTP endpoints are reachable from the host. Uses retry with exponential backoff: + +```csharp +await DashboardVerificationHelpers.PollEndpointAsync( + "http://localhost:18888", + output, + expectedStatusCode: 200, + timeout: TimeSpan.FromSeconds(30)); +``` + +### aspire update Interactive Prompts + +When running `aspire update` with `--channel`, the helper handles these interactive prompts automatically: + +| Prompt | Action | When it appears | +|--------|--------|-----------------| +| "Select a channel:" | Press Enter (default) | Hives exist, no `--channel` flag | +| "Which directory for NuGet.config file?" | Press Enter (accept default) | Explicit channel specified | +| "Apply these changes to NuGet.config?" | Press Enter (yes) | Explicit channel specified | +| "Perform updates?" | Press Enter (yes) | Always | +| "Would you like to update it now?" | Type "n", Enter | CLI self-update prompt | + +### Writing a New Sample Upgrade Test + +1. **Create a new test class** named `SampleUpgrade{SampleName}Tests.cs` +2. **Define constants**: sample path, AppHost csproj path, original version, expected resources +3. **Use `SampleUpgradeHelpers`** for clone, update, run, stop +4. **Use `DashboardVerificationHelpers`** for dashboard verification and endpoint polling +5. **Enable host networking** with `useHostNetwork: true` +6. **Mount a working directory** for direct file assertions from the host + +```csharp +public sealed class SampleUpgradeMyNewSampleTests(ITestOutputHelper output) +{ + private const string SamplePath = "aspire-samples/samples/my-new-sample"; + private const string AppHostCsproj = "MyNewSample.AppHost/MyNewSample.AppHost.csproj"; + private const string OriginalVersion = "13.1.0"; + private static readonly string[] s_expectedResources = ["resource1", "resource2"]; + + [Fact] + public async Task UpgradeAndRunMyNewSample() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var workspace = TemporaryWorkspace.Create(output); + + var workDir = Path.Combine(workspace.WorkspaceRoot.FullName, "sample-work"); + Directory.CreateDirectory(workDir); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, installMode, output, + mountDockerSocket: true, + useHostNetwork: true, + workspace: workspace, + additionalVolumes: [$"{workDir}:/sample-work"]); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(600)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliInDockerAsync(installMode, counter); + + await auto.TypeAsync("cd /sample-work"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.CloneSampleRepoAsync(counter); + + await auto.TypeAsync($"cd {SamplePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + string? channel = null; + if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) + { + channel = $"pr-{CliE2ETestHelpers.GetRequiredPrNumber()}"; + } + + await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", + channel: channel, timeout: TimeSpan.FromMinutes(5)); + + // Verify upgrade via mounted volume + var csproj = await File.ReadAllTextAsync( + Path.Combine(workDir, SamplePath, AppHostCsproj)); + Assert.DoesNotContain(OriginalVersion, csproj); + + // Run and verify dashboard + var runInfo = await auto.AspireRunSampleAsync( + appHostRelativePath: AppHostCsproj, + startTimeout: TimeSpan.FromMinutes(5)); + + if (runInfo.DashboardUrl is not null) + { + await DashboardVerificationHelpers.VerifyDashboardAsync( + runInfo.DashboardUrl, + s_expectedResources, + DashboardVerificationHelpers.GetScreenshotPath(nameof(UpgradeAndRunMyNewSample)), + output); + } + + await auto.StopAspireRunAsync(counter); + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } +} +``` + +### Special Docker Images for Samples + +Some samples may need additional tooling not in the base `Dockerfile.e2e` (e.g., Go, Java). For these: +- Create a new `DockerfileVariant` enum value +- Add a new Dockerfile (e.g., `Dockerfile.e2e-go`) extending the base image +- Pass the variant to `CreateDockerTestTerminal` + +### CI Artifacts for Sample Tests + +Sample upgrade tests produce these artifacts: +- **Asciinema recording** (`.cast`): Full terminal session replay for debugging +- **Dashboard screenshot** (`.png`): Visual proof of the dashboard state +- **Test output log**: Includes upgrade details, endpoint polling results, resource verification + +All are uploaded to GitHub Actions artifacts under the test job's `logs-*` artifact. diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 2f88d686563..6f55ff86784 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -670,6 +670,7 @@ public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotN options: options, cancellationToken: cancellationToken); } + public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 735d3ddbaff..9b40d51164b 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -25,6 +25,7 @@ internal interface IProjectUpdater internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater { + private bool _isExplicitChannel; public async Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default) { logger.LogDebug("Fetching '{AppHostPath}' items and properties.", projectFile.FullName); @@ -75,6 +76,10 @@ public async Task UpdateProjectAsync(FileInfo projectFile, return new ProjectUpdateResult { UpdatedApplied = false }; } + // Track whether we're using an explicit channel (e.g., PR hive) so that + // individual dotnet package add calls can skip the implicit restore. + _isExplicitChannel = false; + if (channel.Type == PackageChannelType.Explicit) { var (configPathsExitCode, configPaths) = await runner.GetNuGetConfigPathsAsync(projectFile.Directory!, new(), cancellationToken); @@ -114,6 +119,7 @@ public async Task UpdateProjectAsync(FileInfo projectFile, required: true, cancellationToken: cancellationToken); + _isExplicitChannel = true; var nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile); await NuGetConfigMerger.CreateOrUpdateAsync(nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken); } @@ -133,6 +139,24 @@ await interactionService.ShowStatusAsync( return 0; }); + // When using an explicit channel with --no-restore on individual package adds, + // perform a final restore to ensure all packages resolve correctly together. + if (_isExplicitChannel) + { + await interactionService.ShowStatusAsync( + UpdateCommandStrings.RestoringPackages, + async () => + { + var exitCode = await runner.RestoreAsync(projectFile, new(), cancellationToken); + if (exitCode != 0) + { + throw new ProjectUpdaterException(UpdateCommandStrings.FailedRestoreAfterUpdate); + } + + return 0; + }); + } + interactionService.DisplayEmptyLine(); interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage); @@ -877,12 +901,19 @@ private static async Task UpdatePackageVersionInDirectoryPackagesProps(string pa private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPackageCli package, CancellationToken cancellationToken) { + // When using an explicit channel, skip the implicit restore during package add to + // avoid failures from NuGet package source mapping. The mapping routes Aspire* packages + // exclusively to the hive source, but during the restore triggered by dotnet package add, + // other Aspire packages not yet updated still reference old versions that don't exist in + // the hive. A final restore is performed after all packages are updated. + var noRestore = _isExplicitChannel; + var exitCode = await runner.AddPackageAsync( projectFilePath: projectFile, packageName: package.Id, packageVersion: package.Version, nugetSource: null, - noRestore: false, + noRestore: noRestore, options: new(), cancellationToken: cancellationToken); diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index fd4c0c24794..b47f0b3aed8 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -116,5 +116,7 @@ internal static string ProjectArgumentDescription { internal static string RegeneratingSdkCode => ResourceManager.GetString("RegeneratingSdkCode", resourceCulture); internal static string RegeneratedSdkCode => ResourceManager.GetString("RegeneratedSdkCode", resourceCulture); internal static string SelfOptionDescription => ResourceManager.GetString("SelfOptionDescription", resourceCulture); + internal static string RestoringPackages => ResourceManager.GetString("RestoringPackages", resourceCulture); + internal static string FailedRestoreAfterUpdate => ResourceManager.GetString("FailedRestoreAfterUpdate", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 27afa3a4b68..f3accd7de4b 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -165,4 +165,10 @@ Update the Aspire CLI itself to the latest version + + Restoring packages... + + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index c8371a36bb2..9c2813d5c2d 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -107,6 +107,11 @@ Nepovedlo se načíst položky a vlastnosti pro projekt: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Nepodařilo se aktualizovat odkaz na balíček pro {0} v projektu {1}. @@ -217,6 +222,11 @@ Odebrán zastaralý odkaz na balíček Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Zachováno: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index da5b1f31d53..2e4e154f7d2 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -107,6 +107,11 @@ Beim Abrufen von Elementen und Eigenschaften für das Projekt {0} ist ein Fehler aufgetreten. + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Beim Aktualisieren des Paketverweises für {0} im Projekt {1} ist ein Fehler aufgetreten. @@ -217,6 +222,11 @@ Veralteter Verweis auf das Aspire.Hosting.AppHost-Paket entfernt + + Restoring packages... + Restoring packages... + + Retained: {0} Beibehalten: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 4a268c57498..e9d357350e6 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -107,6 +107,11 @@ Error al capturar los elementos y propiedades del proyecto: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. No se pudo actualizar la referencia del paquete para {0} en el proyecto {1}. @@ -217,6 +222,11 @@ Se quitó la referencia al paquete en desuso Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Retenido: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index a38af861e0d..f21484572a9 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -107,6 +107,11 @@ Échec de la récupération des éléments et des propriétés du projet : {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Échec de la mise à jour de la référence de package pour {0} dans le projet {1}. @@ -217,6 +222,11 @@ Suppression de la référence au package obsolète Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Retenu : {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index 4ea8bd78556..e8d89bc0611 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -107,6 +107,11 @@ Non è possibile recuperare elementi e proprietà per il progetto: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Non è possibile aggiornare il riferimento al pacchetto per {0} nel progetto {1}. @@ -217,6 +222,11 @@ Rimosso riferimento al pacchetto Aspire.Hosting.AppHost obsoleto + + Restoring packages... + Restoring packages... + + Retained: {0} Mantenuto: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index cbf0d6c98bc..53b3cea8c01 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -107,6 +107,11 @@ プロジェクト {0} のアイテムとプロパティのフェッチに失敗しました + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. プロジェクト {1} の {0} に対するパッケージ参照の更新に失敗しました。 @@ -217,6 +222,11 @@ 古い Aspire.Hosting.AppHost パッケージ参照を削除しました + + Restoring packages... + Restoring packages... + + Retained: {0} 保持済み: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index 23cfe07ed9e..32fa38d8742 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -107,6 +107,11 @@ 프로젝트의 항목 및 속성을 가져오지 못했습니다. {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. 프로젝트 {1}에서 {0}에 대한 패키지 참조를 업데이트하지 못 했습니다. @@ -217,6 +222,11 @@ 더 이상 사용하지 않는 Aspire.Hosting.AppHost 패키지 참조를 제거함 + + Restoring packages... + Restoring packages... + + Retained: {0} 보존됨: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index bd7bbda5f47..c2a0a7186bf 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -107,6 +107,11 @@ Nie można pobrać elementów i właściwości projektu: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Nie można zaktualizować odwołania do pakietu {0} w projekcie {1}. @@ -217,6 +222,11 @@ Usunięto przestarzałe odwołanie do pakietu Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Utrzymano: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index c06d319b603..a7616a97f32 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -107,6 +107,11 @@ Falha ao buscar itens e propriedades para o projeto: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Falha ao atualizar a referência de pacote para {0} no projeto {1}. @@ -217,6 +222,11 @@ Removida a referência obsoleta ao pacote Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Retidos: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index d8e105c25be..0cfa8426499 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -107,6 +107,11 @@ Не удалось получить элементы и свойства для проекта: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. Не удалось обновить ссылку на пакет для {0} в проекте {1}. @@ -217,6 +222,11 @@ Удалена устаревшая ссылка на пакет Aspire.Hosting.AppHost + + Restoring packages... + Restoring packages... + + Retained: {0} Сохранено: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index 867328a2c1f..5c026255dff 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -107,6 +107,11 @@ Proje için öğeler ve özellikler getirilemedi: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. {1} projesinde {0} için paket başvurusu güncellenemedi. @@ -217,6 +222,11 @@ Kullanımdan kaldırılan Aspire.Hosting.AppHost paket başvurusu kaldırıldı + + Restoring packages... + Restoring packages... + + Retained: {0} Korundu: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index ec47945cd27..6c33b9b9e34 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -107,6 +107,11 @@ 无法提取项目的项和属性: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. 未能更新项目 {1} 中 {0} 的包引用。 @@ -217,6 +222,11 @@ 已移除过时的 Aspire.Hosting.AppHost 包引用 + + Restoring packages... + Restoring packages... + + Retained: {0} 已保留: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index ee61c8f937a..a752fa153f9 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -107,6 +107,11 @@ 無法為專案擷取項目與屬性: {0} + + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + Failed to restore packages after update. You may need to run 'dotnet restore' manually. + + Failed to update package reference for {0} in project {1}. 無法更新專案 {1} 中 {0} 的套件參考。 @@ -217,6 +222,11 @@ 已移除過時的 Aspire.Hosting.AppHost 套件參考 + + Restoring packages... + Restoring packages... + + Retained: {0} 已保留: {0} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index c7ee32971e9..b08a4d18eb7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index ed41c1a899b..fc37c3aa658 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -173,6 +173,9 @@ internal static DockerInstallMode DetectDockerInstallMode(string repoRoot) /// Test output helper for logging configuration details. /// Which Dockerfile variant to use (DotNet or Polyglot). /// Whether to mount the Docker socket for DCP/container access. + /// Whether to use Docker host network mode. When true, the container shares the host's + /// network namespace, making all container-bound ports directly accessible from the test process. This is useful for + /// accessing the Aspire dashboard and app endpoints via Playwright or HttpClient. /// Optional workspace to mount into the container at /workspace. /// Terminal width in columns. /// Terminal height in rows. @@ -184,6 +187,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( ITestOutputHelper output, DockerfileVariant variant = DockerfileVariant.DotNet, bool mountDockerSocket = false, + bool useHostNetwork = false, TemporaryWorkspace? workspace = null, IEnumerable? additionalVolumes = null, int width = 160, @@ -206,6 +210,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( output.WriteLine($" Dockerfile: {dockerfilePath}"); output.WriteLine($" Workspace: {workspace?.WorkspaceRoot.FullName ?? "(none)"}"); output.WriteLine($" Docker socket: {mountDockerSocket}"); + output.WriteLine($" Host network: {useHostNetwork}"); output.WriteLine($" Dimensions: {width}x{height}"); output.WriteLine($" Recording: {recordingPath}"); @@ -223,6 +228,11 @@ internal static Hex1bTerminal CreateDockerTestTerminal( c.MountDockerSocket = true; } + if (useHostNetwork) + { + c.Network = "host"; + } + if (workspace is not null) { // Mount using the same directory name so that diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/DashboardVerificationHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/DashboardVerificationHelpers.cs new file mode 100644 index 00000000000..4ed565c2c60 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/DashboardVerificationHelpers.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Playwright; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +/// +/// Provides Playwright-based helpers for verifying the Aspire dashboard is functional +/// and showing expected resources in the correct state. Screenshots are saved to the +/// test results directory for CI artifact upload. +/// +internal static class DashboardVerificationHelpers +{ + private static bool s_browsersInstalled; + private static readonly object s_installLock = new(); + + /// + /// Ensures Playwright Chromium browsers are installed. Safe to call multiple times; + /// installation is performed only once per process. + /// + internal static void EnsureBrowsersInstalled() + { + if (s_browsersInstalled) + { + return; + } + + lock (s_installLock) + { + if (s_browsersInstalled) + { + return; + } + + var exitCode = Microsoft.Playwright.Program.Main(["install", "chromium", "--with-deps"]); + if (exitCode != 0) + { + throw new InvalidOperationException( + $"Playwright browser installation failed with exit code {exitCode}. " + + "Ensure the CI environment supports Playwright browser installation."); + } + + s_browsersInstalled = true; + } + } + + /// + /// Verifies the Aspire dashboard is accessible, displays the expected resources, + /// and takes a screenshot for test artifacts. + /// + /// The full dashboard login URL (including auth token). + /// Resource names that should appear in the dashboard. + /// File path to save the dashboard screenshot. + /// Test output helper for logging. + /// Timeout for dashboard verification. Defaults to 60 seconds. + internal static async Task VerifyDashboardAsync( + string dashboardUrl, + IEnumerable expectedResourceNames, + string screenshotPath, + ITestOutputHelper output, + TimeSpan? timeout = null) + { + var effectiveTimeout = (float)(timeout ?? TimeSpan.FromSeconds(60)).TotalMilliseconds; + + EnsureBrowsersInstalled(); + + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + }); + + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true, + ViewportSize = new ViewportSize { Width = 1920, Height = 1080 }, + }); + + var page = await context.NewPageAsync(); + page.SetDefaultTimeout(effectiveTimeout); + + output.WriteLine($"Navigating to dashboard: {dashboardUrl}"); + await page.GotoAsync(dashboardUrl, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = effectiveTimeout, + }); + + // The login URL auto-authenticates via the token parameter and redirects to the + // resources page. Wait for the resources table to render. + output.WriteLine("Waiting for resources table to load..."); + await page.WaitForSelectorAsync( + "fluent-data-grid, [class*='resource'], table", + new PageWaitForSelectorOptions { Timeout = effectiveTimeout }); + + // Give the dashboard a moment to finish rendering resource states + await page.WaitForTimeoutAsync(3000); + + // Verify expected resources are present in the page content + var pageContent = await page.ContentAsync(); + var missingResources = new List(); + + foreach (var resourceName in expectedResourceNames) + { + if (pageContent.Contains(resourceName, StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine($" ✓ Found resource: {resourceName}"); + } + else + { + output.WriteLine($" ✗ Missing resource: {resourceName}"); + missingResources.Add(resourceName); + } + } + + // Take screenshot before asserting so we always get the artifact + var screenshotDir = Path.GetDirectoryName(screenshotPath); + if (screenshotDir is not null) + { + Directory.CreateDirectory(screenshotDir); + } + + await page.ScreenshotAsync(new PageScreenshotOptions + { + Path = screenshotPath, + FullPage = true, + }); + output.WriteLine($"Dashboard screenshot saved to: {screenshotPath}"); + + Assert.Empty(missingResources); + } + + /// + /// Polls an HTTP endpoint from the host test process until it responds with the expected status code. + /// Uses retry logic with exponential backoff. + /// + /// The URL to poll. + /// Test output helper for logging. + /// Expected HTTP status code. Defaults to 200. + /// Overall timeout. Defaults to 60 seconds. + internal static async Task PollEndpointAsync( + string url, + ITestOutputHelper output, + int expectedStatusCode = 200, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }; + var deadline = DateTime.UtcNow + effectiveTimeout; + var delay = TimeSpan.FromSeconds(2); + var attempt = 0; + + output.WriteLine($"Polling endpoint: {url} (expecting {expectedStatusCode})"); + + while (DateTime.UtcNow < deadline) + { + attempt++; + try + { + var response = await client.GetAsync(url); + var statusCode = (int)response.StatusCode; + + if (statusCode == expectedStatusCode) + { + output.WriteLine($" ✓ Endpoint responded with {statusCode} on attempt {attempt}"); + return; + } + + output.WriteLine($" Attempt {attempt}: got {statusCode}, expected {expectedStatusCode}"); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + output.WriteLine($" Attempt {attempt}: {ex.GetType().Name} - {ex.Message}"); + } + + await Task.Delay(delay); + delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 1.5, 10_000)); + } + + Assert.Fail($"Endpoint {url} did not respond with {expectedStatusCode} within {effectiveTimeout.TotalSeconds}s after {attempt} attempts"); + } + + /// + /// Returns the standard path for dashboard screenshots within the test results directory. + /// In CI, screenshots go under $GITHUB_WORKSPACE/testresults/screenshots/ for artifact upload. + /// Locally, they go to the system temp directory. + /// + /// The name of the test, used as the screenshot filename. + /// The full path to save the screenshot. + internal static string GetScreenshotPath(string testName) + { + var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); + string screenshotsDir; + + if (!string.IsNullOrEmpty(githubWorkspace)) + { + screenshotsDir = Path.Combine(githubWorkspace, "testresults", "screenshots"); + } + else + { + screenshotsDir = Path.Combine(Path.GetTempPath(), "aspire-cli-e2e", "screenshots"); + } + + Directory.CreateDirectory(screenshotsDir); + return Path.Combine(screenshotsDir, $"{testName}.png"); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs new file mode 100644 index 00000000000..1731867a37f --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -0,0 +1,278 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Hex1b.Automation; +using Hex1b.Input; + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +/// +/// Information captured from a running aspire run session. +/// +/// The full dashboard login URL including auth token, or null if not found. +internal sealed record AspireRunInfo(string? DashboardUrl); + +/// +/// Extension methods for providing helpers for +/// sample upgrade E2E tests. These tests clone external repos (e.g., dotnet/aspire-samples), +/// run aspire update to upgrade them to the PR/CI build, and then run the apphost +/// to verify the sample works correctly. +/// +internal static class SampleUpgradeHelpers +{ + private const string DefaultSamplesRepoUrl = "https://github.com/dotnet/aspire-samples.git"; + private const string DefaultSamplesBranch = "main"; + + private static readonly Regex s_dashboardUrlRegex = new( + @"https?://[^\s]+/login\?t=[^\s]+", + RegexOptions.Compiled); + + /// + /// Clones a Git repository inside the container. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// The Git repository URL. Defaults to dotnet/aspire-samples. + /// The branch to clone. Defaults to main. + /// The clone depth. Defaults to 1 for shallow clone. + /// Timeout for the clone operation. Defaults to 120 seconds. + internal static async Task CloneSampleRepoAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string repoUrl = DefaultSamplesRepoUrl, + string branch = DefaultSamplesBranch, + int depth = 1, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(120); + + await auto.TypeAsync($"git clone --depth {depth} --single-branch --branch {branch} {repoUrl}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); + } + + /// + /// Runs aspire update on a cloned sample, handling interactive prompts. + /// Navigates to the sample directory, runs the update, and handles channel selection + /// and CLI update prompts. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// The relative path to the sample directory from the current working directory (e.g., aspire-samples/samples/aspire-with-node). + /// Optional channel name to pass via --channel. When set, bypasses the interactive channel selection prompt and ensures the specified channel's packages are used. + /// Timeout for the update operation. Defaults to 180 seconds. + internal static async Task AspireUpdateInSampleAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string samplePath, + string? channel = null, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(180); + + // Navigate to the sample directory + await auto.TypeAsync($"cd {samplePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Run aspire update. When a channel is specified (e.g., "pr-15421"), pass it + // explicitly via --channel to ensure PR hive packages are used for the upgrade + // instead of the default nuget.org stable versions. + var command = channel is not null + ? $"aspire update --channel {channel}" + : "aspire update"; + await auto.TypeAsync(command); + await auto.EnterAsync(); + + // Wait for completion. Handle interactive prompts along the way: + // When using an explicit channel (e.g., PR hive), additional NuGet config prompts appear: + // 1. "Which directory for NuGet.config file?" — accept default (Enter) + // 2. "Apply these changes to NuGet.config?" — accept (Enter, default is yes) + // Then for all modes: + // 3. Channel selection prompt (if hives exist and no --channel) — select default (Enter) + // 4. "Perform updates?" confirmation — accept (Enter, default is yes) + // 5. CLI update prompt (after package update) — decline (type 'n') + var expectedCounter = counter.Value; + var channelPromptHandled = false; + var nugetConfigDirPromptHandled = false; + var nugetConfigApplyPromptHandled = false; + var performUpdatesPromptHandled = false; + var cliUpdatePromptHandled = false; + + await auto.WaitUntilAsync(snapshot => + { + // Check if the command completed (success or error) + var successSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" OK] $ "); + if (successSearcher.Search(snapshot).Count > 0) + { + return true; + } + + var errorSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" ERR:"); + if (errorSearcher.Search(snapshot).Count > 0) + { + return true; + } + + // Handle "Select a channel:" prompt — select the first option (Enter) + if (!channelPromptHandled && snapshot.ContainsText("Select a channel:")) + { + channelPromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.EnterAsync(); + }); + } + + // Handle "Which directory for NuGet.config file?" prompt — accept default (Enter) + if (!nugetConfigDirPromptHandled && snapshot.ContainsText("NuGet.config file?")) + { + nugetConfigDirPromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.EnterAsync(); + }); + } + + // Handle "Apply these changes to NuGet.config?" prompt — accept (Enter) + if (!nugetConfigApplyPromptHandled && snapshot.ContainsText("Apply these changes to NuGet.config?")) + { + nugetConfigApplyPromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.EnterAsync(); + }); + } + + // Handle "Perform updates?" confirmation — accept default (Enter) + if (!performUpdatesPromptHandled && snapshot.ContainsText("Perform updates?")) + { + performUpdatesPromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.EnterAsync(); + }); + } + + // Handle "Would you like to update it now?" CLI update prompt — decline + if (!cliUpdatePromptHandled && snapshot.ContainsText("Would you like to update it now?")) + { + cliUpdatePromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.TypeAsync("n"); + await auto.EnterAsync(); + }); + } + + return false; + }, timeout: effectiveTimeout, description: "aspire update to complete"); + + counter.Increment(); + } + + /// + /// Runs aspire run on a sample and waits for the apphost to start. + /// Returns an containing the dashboard URL parsed from terminal output. + /// + /// The terminal automator. + /// Optional relative path to the AppHost csproj file. If specified, passed as --apphost. + /// Timeout for the apphost to start. Defaults to 5 minutes. + /// An containing the dashboard login URL. + internal static async Task AspireRunSampleAsync( + this Hex1bTerminalAutomator auto, + string? appHostRelativePath = null, + TimeSpan? startTimeout = null) + { + var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(5); + + var command = appHostRelativePath is not null + ? $"aspire run --apphost {appHostRelativePath}" + : "aspire run"; + + await auto.TypeAsync(command); + await auto.EnterAsync(); + + string? dashboardUrl = null; + + // Wait for the apphost to start successfully, capturing the dashboard URL along the way + await auto.WaitUntilAsync(s => + { + // Fail fast if apphost selection prompt appears (multiple apphosts detected) + if (s.ContainsText("Select an apphost to use:")) + { + throw new InvalidOperationException( + "Unexpected apphost selection prompt detected! " + + "This indicates multiple apphosts were incorrectly detected in the sample."); + } + + // Try to capture the dashboard login URL from terminal output + if (dashboardUrl is null) + { + var screenText = s.GetScreenText(); + var match = s_dashboardUrlRegex.Match(screenText); + if (match.Success) + { + dashboardUrl = match.Value; + } + } + + return s.ContainsText("Press CTRL+C to stop the apphost and exit."); + }, timeout: effectiveTimeout, description: "aspire run to start (Press CTRL+C message)"); + + return new AspireRunInfo(dashboardUrl); + } + + /// + /// Stops a running aspire run instance by sending Ctrl+C. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// Timeout for the stop operation. Defaults to 60 seconds. + internal static async Task StopAspireRunAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); + + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); + } + + /// + /// Verifies an HTTP endpoint is reachable from inside the container using curl. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// The URL to check. + /// The expected HTTP status code. Defaults to 200. + /// Timeout for the HTTP request. Defaults to 30 seconds. + internal static async Task VerifyHttpEndpointAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string url, + int expectedStatusCode = 200, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); + var marker = $"endpoint-http-{expectedStatusCode}"; + + await auto.TypeAsync( + $"curl -ksSL -o /dev/null -w 'endpoint-http-%{{http_code}}' \"{url}\" " + + "|| echo 'endpoint-http-failed'"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(marker, timeout: effectiveTimeout); + await auto.WaitForSuccessPromptAsync(counter); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs new file mode 100644 index 00000000000..d87b36691ed --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// E2E test that clones the aspire-with-node sample from dotnet/aspire-samples, +/// upgrades it to the PR/CI build using aspire update, and verifies it runs correctly. +/// The sample consists of a Node.js Express frontend, an ASP.NET Core weather API, and Redis. +/// +public sealed class SampleUpgradeAspireWithNodeTests(ITestOutputHelper output) +{ + private const string SamplePath = "aspire-samples/samples/aspire-with-node"; + private const string AppHostCsproj = "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj"; + private const string OriginalVersion = "13.1.0"; + + /// + /// Expected resource names that should appear in the Aspire dashboard after running the sample. + /// + private static readonly string[] s_expectedResources = ["cache", "weatherapi", "frontend"]; + + [Fact] + public async Task UpgradeAndRunAspireWithNodeSample() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + + var workspace = TemporaryWorkspace.Create(output); + + // Mount a host-side working directory into the container so we can + // inspect files directly from the test process after the upgrade. + var workDir = Path.Combine(workspace.WorkspaceRoot.FullName, "sample-work"); + Directory.CreateDirectory(workDir); + const string containerWorkDir = "/sample-work"; + + // Use host networking so the Aspire dashboard and app endpoints are directly + // accessible from the test process for Playwright and HttpClient verification. + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, installMode, output, + mountDockerSocket: true, + useHostNetwork: true, + workspace: workspace, + additionalVolumes: [$"{workDir}:{containerWorkDir}"]); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(600)); + + // Prepare Docker environment (prompt counting, umask, env vars) + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + + // Install the Aspire CLI + await auto.InstallAspireCliInDockerAsync(installMode, counter); + + // Clone the aspire-samples repository into the mounted working directory + await auto.TypeAsync($"cd {containerWorkDir}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.CloneSampleRepoAsync(counter); + + // Navigate to the sample directory + await auto.TypeAsync($"cd {SamplePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Run aspire update with the PR channel to upgrade to the PR build. + string? channel = null; + if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) + { + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + channel = $"pr-{prNumber}"; + } + + await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", + channel: channel, + timeout: TimeSpan.FromMinutes(5)); + + // Verify the upgrade by reading the csproj directly from the mounted volume + var hostCsprojPath = Path.Combine(workDir, SamplePath, AppHostCsproj); + var csprojContent = await File.ReadAllTextAsync(hostCsprojPath); + output.WriteLine($"--- AppHost csproj after upgrade ---"); + output.WriteLine(csprojContent); + + Assert.DoesNotContain(OriginalVersion, csprojContent); + + if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) + { + Assert.Contains("-pr.", csprojContent); + } + + // Run the sample and capture the dashboard URL + var runInfo = await auto.AspireRunSampleAsync( + appHostRelativePath: AppHostCsproj, + startTimeout: TimeSpan.FromMinutes(5)); + + output.WriteLine($"Dashboard URL: {runInfo.DashboardUrl ?? "(not captured)"}"); + + // Verify the dashboard is accessible and shows expected resources via Playwright + if (runInfo.DashboardUrl is not null) + { + var screenshotPath = DashboardVerificationHelpers.GetScreenshotPath( + nameof(UpgradeAndRunAspireWithNodeSample)); + output.WriteLine($"Screenshot will be saved to: {screenshotPath}"); + + await DashboardVerificationHelpers.VerifyDashboardAsync( + runInfo.DashboardUrl, + s_expectedResources, + screenshotPath, + output, + timeout: TimeSpan.FromSeconds(90)); + + // Also poll the dashboard endpoint from the host to confirm HTTP accessibility + var dashboardBase = new Uri(runInfo.DashboardUrl).GetLeftPart(UriPartial.Authority); + await DashboardVerificationHelpers.PollEndpointAsync( + dashboardBase, + output, + timeout: TimeSpan.FromSeconds(30)); + } + else + { + output.WriteLine("WARNING: Dashboard URL was not captured from terminal output. Skipping dashboard verification."); + } + + // Stop the running apphost + await auto.StopAspireRunAsync(counter); + + // Exit the shell + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 32101c9ae3a..38ca2183225 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -50,7 +50,7 @@ public Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocatio { return RestoreAsyncCallback != null ? Task.FromResult(RestoreAsyncCallback(projectFilePath, options, cancellationToken)) - : throw new NotImplementedException(); + : Task.FromResult(0); } public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)