From 70a83285964c34557844fc5be2d0724d1d39fa58 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 12:22:45 +1100 Subject: [PATCH 01/14] [cli] Add E2E sample upgrade tests for aspire-samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add infrastructure for testing that PR/CI builds of Aspire can upgrade and run external repos from dotnet/aspire-samples. Includes: - SampleUpgradeHelpers.cs: Shared helpers for clone, update, run, and verify workflows (CloneSampleRepoAsync, AspireUpdateInSampleAsync, AspireRunSampleAsync, StopAspireRunAsync, VerifyHttpEndpointAsync) - SampleUpgradeAspireWithNodeTests.cs: First sample test targeting the aspire-with-node sample (Node.js + .NET + Redis) Each test class becomes its own CI job via SplitTestsOnCI. The flow is: install CLI from PR → git clone aspire-samples → aspire update → aspire run → verify → Ctrl+C stop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 206 ++++++++++++++++++ .../SampleUpgradeAspireWithNodeTests.cs | 62 ++++++ 2 files changed, 268 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs 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..901d6eeebb1 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b.Automation; +using Hex1b.Input; + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +/// +/// 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"; + + /// + /// 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). + /// Timeout for the update operation. Defaults to 180 seconds. + internal static async Task AspireUpdateInSampleAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string samplePath, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(180); + + // Navigate to the sample directory + await auto.TypeAsync($"cd {samplePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Run aspire update. The behavior depends on the install mode: + // - PR mode (hives exist): May prompt for channel selection ("Select a channel:") + // - GA/source mode (no hives): Auto-selects the implicit/default channel + // After update, may prompt to update CLI ("Would you like to update it now?") + await auto.TypeAsync("aspire update"); + await auto.EnterAsync(); + + // Wait for completion. Handle interactive prompts along the way: + // 1. Channel selection prompt (if hives exist) — select default (Enter) + // 2. CLI update prompt (after package update) — decline (type 'n') + var expectedCounter = counter.Value; + var channelPromptHandled = false; + var cliUpdatePromptHandled = false; + + await auto.WaitUntilAsync(snapshot => + { + // Check if the command completed (success or error) + var successSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" OK] $ "); + if (successSearcher.Search(snapshot).Count > 0) + { + return true; + } + + var errorSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString()) + .RightText(" ERR:"); + if (errorSearcher.Search(snapshot).Count > 0) + { + return true; + } + + // Handle "Select a channel:" prompt — select the first option (Enter) + if (!channelPromptHandled && snapshot.ContainsText("Select a channel:")) + { + channelPromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.EnterAsync(); + }); + } + + // Handle "Would you like to update it now?" CLI update prompt — decline + if (!cliUpdatePromptHandled && snapshot.ContainsText("Would you like to update it now?")) + { + cliUpdatePromptHandled = true; + _ = Task.Run(async () => + { + await auto.WaitAsync(500); + await auto.TypeAsync("n"); + await auto.EnterAsync(); + }); + } + + return false; + }, timeout: effectiveTimeout, description: "aspire update to complete"); + + counter.Increment(); + } + + /// + /// Runs aspire run on a sample and waits for the apphost to start. + /// Returns when the "Press CTRL+C to stop the apphost and exit." message is displayed. + /// + /// 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. + internal static async Task AspireRunSampleAsync( + this Hex1bTerminalAutomator auto, + string? appHostRelativePath = null, + TimeSpan? startTimeout = null) + { + var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(5); + + var command = appHostRelativePath is not null + ? $"aspire run --apphost {appHostRelativePath}" + : "aspire run"; + + await auto.TypeAsync(command); + await auto.EnterAsync(); + + // Wait for the apphost to start successfully + await auto.WaitUntilAsync(s => + { + // Fail fast if apphost selection prompt appears (multiple apphosts detected) + if (s.ContainsText("Select an apphost to use:")) + { + throw new InvalidOperationException( + "Unexpected apphost selection prompt detected! " + + "This indicates multiple apphosts were incorrectly detected in the sample."); + } + + return s.ContainsText("Press CTRL+C to stop the apphost and exit."); + }, timeout: effectiveTimeout, description: "aspire run to start (Press CTRL+C message)"); + } + + /// + /// 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..f0a4f444d46 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// 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) +{ + [Fact] + public async Task UpgradeAndRunAspireWithNodeSample() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, installMode, output, + mountDockerSocket: true, + workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(600)); + + // Prepare Docker environment (prompt counting, umask, env vars) + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + + // Install the Aspire CLI + await auto.InstallAspireCliInDockerAsync(installMode, counter); + + // Clone the aspire-samples repository + await auto.CloneSampleRepoAsync(counter); + + // Update the aspire-with-node sample to the PR/CI build + await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node"); + + // Run the sample — the AppHost csproj is in the AspireWithNode.AppHost subdirectory + await auto.AspireRunSampleAsync( + appHostRelativePath: "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", + startTimeout: TimeSpan.FromMinutes(5)); + + // Stop the running apphost + await auto.StopAspireRunAsync(counter); + + // Exit the shell + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} From e6f2cacc0ecaa775bccbbd3da28b36cf1af33cbf Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 12:41:37 +1100 Subject: [PATCH 02/14] Fix aspire update prompt handling and add upgrade verification - Handle "Perform updates? [y/n]" confirmation prompt (was causing timeout) - Add VerifySampleWasUpgradedAsync helper that checks the csproj no longer contains the original version and dumps the updated csproj to the recording - Call verification step in the aspire-with-node test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 42 ++++++++++++++++++- .../SampleUpgradeAspireWithNodeTests.cs | 5 +++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 901d6eeebb1..58b38f8c1e6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -72,9 +72,11 @@ internal static async Task AspireUpdateInSampleAsync( // Wait for completion. Handle interactive prompts along the way: // 1. Channel selection prompt (if hives exist) — select default (Enter) - // 2. CLI update prompt (after package update) — decline (type 'n') + // 2. "Perform updates?" confirmation — accept (Enter, default is yes) + // 3. CLI update prompt (after package update) — decline (type 'n') var expectedCounter = counter.Value; var channelPromptHandled = false; + var performUpdatesPromptHandled = false; var cliUpdatePromptHandled = false; await auto.WaitUntilAsync(snapshot => @@ -107,6 +109,17 @@ await auto.WaitUntilAsync(snapshot => }); } + // 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?")) { @@ -178,6 +191,33 @@ internal static async Task StopAspireRunAsync( await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); } + /// + /// Verifies that a sample's AppHost csproj was actually upgraded by checking that it + /// no longer contains the original version string. This ensures aspire update + /// actually modified the project file. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// Relative path to the AppHost csproj from the sample directory. + /// The original Aspire version the sample was pinned to (e.g., 13.1.0). + internal static async Task VerifySampleWasUpgradedAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string csprojRelativePath, + string originalVersion) + { + // Check that the original version is no longer in the csproj + await auto.TypeAsync($"grep -c '{originalVersion}' {csprojRelativePath} && echo 'UPGRADE_VERIFY_FAIL: still contains {originalVersion}' || echo 'UPGRADE_VERIFY_OK: no longer contains {originalVersion}'"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("UPGRADE_VERIFY_OK", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForAnyPromptAsync(counter); + + // Also print the current csproj for the recording so we can see what it was updated to + await auto.TypeAsync($"cat {csprojRelativePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + /// /// Verifies an HTTP endpoint is reachable from inside the container using curl. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index f0a4f444d46..0144f78b7e1 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -45,6 +45,11 @@ public async Task UpgradeAndRunAspireWithNodeSample() // Update the aspire-with-node sample to the PR/CI build await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node"); + // Verify that the AppHost csproj was actually updated (no longer contains 13.1.0) + await auto.VerifySampleWasUpgradedAsync(counter, + "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", + originalVersion: "13.1.0"); + // Run the sample — the AppHost csproj is in the AspireWithNode.AppHost subdirectory await auto.AspireRunSampleAsync( appHostRelativePath: "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", From 6bd67c70269c369eb7918a113d87e3dc25ff29c8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 13:09:21 +1100 Subject: [PATCH 03/14] Use explicit PR channel for aspire update to ensure PR packages are used - Pass --channel pr-{N} when in PullRequest install mode so aspire update resolves packages from the PR hive instead of stable nuget.org - Add handlers for NuGet.config prompts that appear with explicit channels: "Which directory for NuGet.config file?" and "Apply these changes to NuGet.config?" - These prompts only appear when using explicit (non-implicit) channels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 48 +++++++++++++++---- .../SampleUpgradeAspireWithNodeTests.cs | 9 +++- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 58b38f8c1e6..ce1d5917d17 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -49,11 +49,13 @@ internal static async Task CloneSampleRepoAsync( /// 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); @@ -63,19 +65,27 @@ internal static async Task AspireUpdateInSampleAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Run aspire update. The behavior depends on the install mode: - // - PR mode (hives exist): May prompt for channel selection ("Select a channel:") - // - GA/source mode (no hives): Auto-selects the implicit/default channel - // After update, may prompt to update CLI ("Would you like to update it now?") - await auto.TypeAsync("aspire update"); + // 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: - // 1. Channel selection prompt (if hives exist) — select default (Enter) - // 2. "Perform updates?" confirmation — accept (Enter, default is yes) - // 3. CLI update prompt (after package update) — decline (type 'n') + // 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; @@ -109,6 +119,28 @@ await auto.WaitUntilAsync(snapshot => }); } + // 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?")) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index 0144f78b7e1..550fd6945b0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -42,8 +42,15 @@ public async Task UpgradeAndRunAspireWithNodeSample() // Clone the aspire-samples repository await auto.CloneSampleRepoAsync(counter); + // Determine the update channel. In PullRequest mode, explicitly pass the PR channel + // so that aspire update uses the PR hive packages instead of stable nuget.org versions. + string? updateChannel = installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest + ? $"pr-{CliE2ETestHelpers.GetRequiredPrNumber()}" + : null; + // Update the aspire-with-node sample to the PR/CI build - await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node"); + await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node", + channel: updateChannel); // Verify that the AppHost csproj was actually updated (no longer contains 13.1.0) await auto.VerifySampleWasUpgradedAsync(counter, From 990f2a1926fbb47fe987795de183549ba2ce7d5f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 13:23:49 +1100 Subject: [PATCH 04/14] Pre-create NuGet.config with PR hive source for aspire update apply phase The aspire update --channel command uses a temp NuGet config for the search phase but the apply phase (dotnet add package) needs the PR hive source in the project NuGet config. Pre-creating NuGet.config ensures packages can be resolved during apply. Also navigate to sample directory before update and increase update timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 39 +++++++++++++++++++ .../SampleUpgradeAspireWithNodeTests.cs | 18 +++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index ce1d5917d17..e19cecd52c1 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -17,6 +17,45 @@ internal static class SampleUpgradeHelpers private const string DefaultSamplesRepoUrl = "https://github.com/dotnet/aspire-samples.git"; private const string DefaultSamplesBranch = "main"; + /// + /// Creates a NuGet.config in the current directory that includes the PR hive packages + /// as a package source. This is needed because aspire update --channel uses a + /// temporary NuGet config for the search phase but the apply phase (dotnet add package) + /// needs the PR hive source in the project's NuGet config to resolve PR-versioned packages. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// The PR channel name (e.g., pr-15421). + internal static async Task SetupPrHiveNuGetConfigAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string channel) + { + // Write a NuGet.config that includes both the PR hive source and nuget.org. + // Package source mapping ensures Aspire* packages come from the hive. + var hivePath = $"/root/.aspire/hives/{channel}/packages"; + var nugetConfig = $@" + + + + + + + + + + + + + +"; + + // Use heredoc to write the file + await auto.TypeAsync($"cat > NuGet.config << 'NUGETEOF'\n{nugetConfig}\nNUGETEOF"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + /// /// Clones a Git repository inside the container. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index 550fd6945b0..3cfab3e6bea 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -48,9 +48,21 @@ public async Task UpgradeAndRunAspireWithNodeSample() ? $"pr-{CliE2ETestHelpers.GetRequiredPrNumber()}" : null; - // Update the aspire-with-node sample to the PR/CI build - await auto.AspireUpdateInSampleAsync(counter, "aspire-samples/samples/aspire-with-node", - channel: updateChannel); + // Navigate to the sample directory first + await auto.TypeAsync("cd aspire-samples/samples/aspire-with-node"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // In PullRequest mode, set up a NuGet.config with the PR hive source so that + // dotnet add package (used by aspire update's apply phase) can resolve PR packages. + if (updateChannel is not null) + { + await auto.SetupPrHiveNuGetConfigAsync(counter, updateChannel); + } + + // Update the sample to the PR/CI build (already in the sample directory) + await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", + channel: updateChannel, timeout: TimeSpan.FromMinutes(5)); // Verify that the AppHost csproj was actually updated (no longer contains 13.1.0) await auto.VerifySampleWasUpgradedAsync(counter, From 07ca65108f77c023dcd09990d2d2a73c2f3ea850 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 13:47:02 +1100 Subject: [PATCH 05/14] Use two-phase upgrade: aspire update + manual PR version fixup aspire update --channel has issues with dotnet package add failing to resolve PR hive packages during the apply phase. Instead: 1. Run aspire update (implicit channel) for structural migration 2. In PR mode, detect the PR version from the hive and use sed to replace all Aspire version strings in the csproj 3. Set up NuGet.config with the PR hive source for restore/build 4. Verify dotnet restore succeeds before running Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 51 +++++++++++++++++++ .../SampleUpgradeAspireWithNodeTests.cs | 31 +++++------ 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index e19cecd52c1..2db7d226373 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -56,6 +56,57 @@ internal static async Task SetupPrHiveNuGetConfigAsync( await auto.WaitForSuccessPromptAsync(counter); } + /// + /// Upgrades all Aspire package references in a csproj to the PR version found in the hive. + /// Detects the PR version by inspecting the hive packages directory, then uses sed + /// to replace all Aspire version strings in the project file and the SDK version in the + /// Project Sdk attribute. + /// + /// The terminal automator. + /// The sequence counter for prompt tracking. + /// The PR channel name (e.g., pr-15421). + /// Relative path to the AppHost csproj from the current directory. + internal static async Task UpgradeToPrVersionAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string channel, + string csprojRelativePath) + { + var hivePath = $"/root/.aspire/hives/{channel}/packages"; + + // Detect the PR version from the hive packages directory. + // Find any Aspire package nupkg and extract its version. + await auto.TypeAsync( + $"PR_VERSION=$(ls {hivePath}/aspire.hosting.redis.*.nupkg 2>/dev/null " + + "| head -1 | sed 's/.*aspire.hosting.redis.//;s/.nupkg//' " + + ") && echo \"PR_VERSION=$PR_VERSION\""); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("PR_VERSION=", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + // Replace all Aspire version strings in the csproj using the detected PR version. + // This handles both PackageReference Version attributes and the Sdk attribute. + await auto.TypeAsync( + $"sed -i -E " + + "'s|(Aspire\\.AppHost\\.Sdk/)([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)|\\1'\"$PR_VERSION\"'|g; " + + "s|(Include=\"Aspire\\.[^\"]+\" Version=\")([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)(\")|\\1'\"$PR_VERSION\"'\\3|g' " + + $"{csprojRelativePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Show the updated csproj for the recording + await auto.TypeAsync($"echo '--- Updated csproj ---' && cat {csprojRelativePath}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Verify restore works with the PR packages + await auto.TypeAsync( + $"dotnet restore {csprojRelativePath} --verbosity quiet && echo 'RESTORE_OK' || echo 'RESTORE_FAILED'"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("RESTORE_OK", timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForAnyPromptAsync(counter); + } + /// /// Clones a Git repository inside the container. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index 3cfab3e6bea..fb1299abc12 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -42,27 +42,28 @@ public async Task UpgradeAndRunAspireWithNodeSample() // Clone the aspire-samples repository await auto.CloneSampleRepoAsync(counter); - // Determine the update channel. In PullRequest mode, explicitly pass the PR channel - // so that aspire update uses the PR hive packages instead of stable nuget.org versions. - string? updateChannel = installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest - ? $"pr-{CliE2ETestHelpers.GetRequiredPrNumber()}" - : null; - - // Navigate to the sample directory first + // Navigate to the sample directory await auto.TypeAsync("cd aspire-samples/samples/aspire-with-node"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // In PullRequest mode, set up a NuGet.config with the PR hive source so that - // dotnet add package (used by aspire update's apply phase) can resolve PR packages. - if (updateChannel is not null) + // Phase 1: Run aspire update to perform structural migration (SDK format, etc.) + // This uses the implicit channel and updates to the latest stable version. + await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", + timeout: TimeSpan.FromMinutes(5)); + + // Phase 2: In PullRequest mode, upgrade all Aspire package versions to the PR build. + // aspire update --channel has issues with dotnet package add and PR hive sources, + // so we do a direct version replacement and set up the NuGet.config for the hive. + if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) { - await auto.SetupPrHiveNuGetConfigAsync(counter, updateChannel); - } + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var channel = $"pr-{prNumber}"; - // Update the sample to the PR/CI build (already in the sample directory) - await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", - channel: updateChannel, timeout: TimeSpan.FromMinutes(5)); + await auto.SetupPrHiveNuGetConfigAsync(counter, channel); + await auto.UpgradeToPrVersionAsync(counter, channel, + "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj"); + } // Verify that the AppHost csproj was actually updated (no longer contains 13.1.0) await auto.VerifySampleWasUpgradedAsync(counter, From c6dc51b1c2f4ace1d0bbfc289122c441f09dbb75 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 14:14:20 +1100 Subject: [PATCH 06/14] Fix PR version detection from hive packages The nupkg filename pattern was case-sensitive and failed to match. Use grep -oE with a version regex pattern instead of sed on a specific package name. Also list hive packages for diagnostics and add a guard that fails fast if version detection returns empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 2db7d226373..0a2025903d2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -74,16 +74,28 @@ internal static async Task UpgradeToPrVersionAsync( { var hivePath = $"/root/.aspire/hives/{channel}/packages"; - // Detect the PR version from the hive packages directory. - // Find any Aspire package nupkg and extract its version. + // First, list the hive packages for diagnostics + await auto.TypeAsync($"echo '--- Hive packages ---' && ls {hivePath}/*.nupkg 2>/dev/null | head -5"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Detect the PR version from ANY nupkg in the hive. + // Extract the version pattern (e.g., 13.3.0-pr.15421.g07ca6510) from the filename. + // NuGet package filenames follow: {id}.{version}.nupkg (case may vary). await auto.TypeAsync( - $"PR_VERSION=$(ls {hivePath}/aspire.hosting.redis.*.nupkg 2>/dev/null " + - "| head -1 | sed 's/.*aspire.hosting.redis.//;s/.nupkg//' " + - ") && echo \"PR_VERSION=$PR_VERSION\""); + $"PR_VERSION=$(ls {hivePath}/*.nupkg 2>/dev/null " + + "| head -1 | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+-pr\\.[0-9]+\\.g[0-9a-f]+' " + + ") && echo \"DETECTED_PR_VERSION=$PR_VERSION\""); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("PR_VERSION=", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitUntilTextAsync("DETECTED_PR_VERSION=", timeout: TimeSpan.FromSeconds(10)); await auto.WaitForSuccessPromptAsync(counter); + // Bail out with a clear error if the version wasn't detected + await auto.TypeAsync("[ -n \"$PR_VERSION\" ] && echo 'VERSION_OK' || { echo 'VERSION_DETECT_FAILED'; false; }"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("VERSION_OK", timeout: TimeSpan.FromSeconds(5)); + await auto.WaitForAnyPromptAsync(counter); + // Replace all Aspire version strings in the csproj using the detected PR version. // This handles both PackageReference Version attributes and the Sdk attribute. await auto.TypeAsync( From 24b73002490c6a13b90f38d00a912c284fb26d71 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 14:57:48 +1100 Subject: [PATCH 07/14] Remove unnecessary dotnet restore step from UpgradeToPrVersionAsync aspire run already restores as part of building. The explicit restore was a debugging checkpoint that is no longer needed and wastes ~30s. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 0a2025903d2..59e999bc459 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -110,13 +110,6 @@ await auto.TypeAsync( await auto.TypeAsync($"echo '--- Updated csproj ---' && cat {csprojRelativePath}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - // Verify restore works with the PR packages - await auto.TypeAsync( - $"dotnet restore {csprojRelativePath} --verbosity quiet && echo 'RESTORE_OK' || echo 'RESTORE_FAILED'"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("RESTORE_OK", timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForAnyPromptAsync(counter); } /// From e70bceb10f10111cd71ad5e71e7d9a6ac2428ea3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 15:00:06 +1100 Subject: [PATCH 08/14] Mount working directory to enable direct file assertions Instead of parsing terminal output with grep to verify upgrades, mount a host temp directory into the Docker container. This lets the test read the csproj via normal file I/O and use Assert.DoesNotContain / Assert.Contains for cleaner, more reliable verification. Removes the VerifySampleWasUpgradedAsync terminal helper in favor of direct File.ReadAllTextAsync on the mounted path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 27 ------------ .../SampleUpgradeAspireWithNodeTests.cs | 44 ++++++++++++++----- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 59e999bc459..68d8567afd8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -318,33 +318,6 @@ internal static async Task StopAspireRunAsync( await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); } - /// - /// Verifies that a sample's AppHost csproj was actually upgraded by checking that it - /// no longer contains the original version string. This ensures aspire update - /// actually modified the project file. - /// - /// The terminal automator. - /// The sequence counter for prompt tracking. - /// Relative path to the AppHost csproj from the sample directory. - /// The original Aspire version the sample was pinned to (e.g., 13.1.0). - internal static async Task VerifySampleWasUpgradedAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter, - string csprojRelativePath, - string originalVersion) - { - // Check that the original version is no longer in the csproj - await auto.TypeAsync($"grep -c '{originalVersion}' {csprojRelativePath} && echo 'UPGRADE_VERIFY_FAIL: still contains {originalVersion}' || echo 'UPGRADE_VERIFY_OK: no longer contains {originalVersion}'"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("UPGRADE_VERIFY_OK", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForAnyPromptAsync(counter); - - // Also print the current csproj for the recording so we can see what it was updated to - await auto.TypeAsync($"cat {csprojRelativePath}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - } - /// /// Verifies an HTTP endpoint is reachable from inside the container using curl. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index fb1299abc12..bf8a1516031 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -15,6 +15,10 @@ namespace Aspire.Cli.EndToEnd.Tests; /// 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"; + [Fact] public async Task UpgradeAndRunAspireWithNodeSample() { @@ -23,10 +27,17 @@ public async Task UpgradeAndRunAspireWithNodeSample() 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"; + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( repoRoot, installMode, output, mountDockerSocket: true, - workspace: workspace); + workspace: workspace, + additionalVolumes: [$"{workDir}:{containerWorkDir}"]); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -39,11 +50,15 @@ public async Task UpgradeAndRunAspireWithNodeSample() // Install the Aspire CLI await auto.InstallAspireCliInDockerAsync(installMode, counter); - // Clone the aspire-samples repository + // 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 aspire-samples/samples/aspire-with-node"); + await auto.TypeAsync($"cd {SamplePath}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); @@ -61,18 +76,25 @@ await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", var channel = $"pr-{prNumber}"; await auto.SetupPrHiveNuGetConfigAsync(counter, channel); - await auto.UpgradeToPrVersionAsync(counter, channel, - "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj"); + await auto.UpgradeToPrVersionAsync(counter, channel, AppHostCsproj); } - // Verify that the AppHost csproj was actually updated (no longer contains 13.1.0) - await auto.VerifySampleWasUpgradedAsync(counter, - "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", - originalVersion: "13.1.0"); + // 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 — the AppHost csproj is in the AspireWithNode.AppHost subdirectory + // Run the sample await auto.AspireRunSampleAsync( - appHostRelativePath: "AspireWithNode.AppHost/AspireWithNode.AppHost.csproj", + appHostRelativePath: AppHostCsproj, startTimeout: TimeSpan.FromMinutes(5)); // Stop the running apphost From 100ba967770441a8e96627183a283ac1fbf1c8c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 15:51:36 +1100 Subject: [PATCH 09/14] Use --channel pr-{N} for direct PR version upgrade Instead of upgrading to stable then using sed to replace versions, pass --channel directly to aspire update so it resolves packages from the PR hive. This tests the actual intended upgrade path. Removes SetupPrHiveNuGetConfigAsync and UpgradeToPrVersionAsync helpers since aspire update handles NuGet.config creation itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/SampleUpgradeHelpers.cs | 95 ------------------- .../SampleUpgradeAspireWithNodeTests.cs | 22 ++--- 2 files changed, 10 insertions(+), 107 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs index 68d8567afd8..79602c5f0fd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -17,101 +17,6 @@ internal static class SampleUpgradeHelpers private const string DefaultSamplesRepoUrl = "https://github.com/dotnet/aspire-samples.git"; private const string DefaultSamplesBranch = "main"; - /// - /// Creates a NuGet.config in the current directory that includes the PR hive packages - /// as a package source. This is needed because aspire update --channel uses a - /// temporary NuGet config for the search phase but the apply phase (dotnet add package) - /// needs the PR hive source in the project's NuGet config to resolve PR-versioned packages. - /// - /// The terminal automator. - /// The sequence counter for prompt tracking. - /// The PR channel name (e.g., pr-15421). - internal static async Task SetupPrHiveNuGetConfigAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter, - string channel) - { - // Write a NuGet.config that includes both the PR hive source and nuget.org. - // Package source mapping ensures Aspire* packages come from the hive. - var hivePath = $"/root/.aspire/hives/{channel}/packages"; - var nugetConfig = $@" - - - - - - - - - - - - - -"; - - // Use heredoc to write the file - await auto.TypeAsync($"cat > NuGet.config << 'NUGETEOF'\n{nugetConfig}\nNUGETEOF"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - } - - /// - /// Upgrades all Aspire package references in a csproj to the PR version found in the hive. - /// Detects the PR version by inspecting the hive packages directory, then uses sed - /// to replace all Aspire version strings in the project file and the SDK version in the - /// Project Sdk attribute. - /// - /// The terminal automator. - /// The sequence counter for prompt tracking. - /// The PR channel name (e.g., pr-15421). - /// Relative path to the AppHost csproj from the current directory. - internal static async Task UpgradeToPrVersionAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter, - string channel, - string csprojRelativePath) - { - var hivePath = $"/root/.aspire/hives/{channel}/packages"; - - // First, list the hive packages for diagnostics - await auto.TypeAsync($"echo '--- Hive packages ---' && ls {hivePath}/*.nupkg 2>/dev/null | head -5"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - // Detect the PR version from ANY nupkg in the hive. - // Extract the version pattern (e.g., 13.3.0-pr.15421.g07ca6510) from the filename. - // NuGet package filenames follow: {id}.{version}.nupkg (case may vary). - await auto.TypeAsync( - $"PR_VERSION=$(ls {hivePath}/*.nupkg 2>/dev/null " + - "| head -1 | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+-pr\\.[0-9]+\\.g[0-9a-f]+' " + - ") && echo \"DETECTED_PR_VERSION=$PR_VERSION\""); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("DETECTED_PR_VERSION=", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - // Bail out with a clear error if the version wasn't detected - await auto.TypeAsync("[ -n \"$PR_VERSION\" ] && echo 'VERSION_OK' || { echo 'VERSION_DETECT_FAILED'; false; }"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("VERSION_OK", timeout: TimeSpan.FromSeconds(5)); - await auto.WaitForAnyPromptAsync(counter); - - // Replace all Aspire version strings in the csproj using the detected PR version. - // This handles both PackageReference Version attributes and the Sdk attribute. - await auto.TypeAsync( - $"sed -i -E " + - "'s|(Aspire\\.AppHost\\.Sdk/)([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)|\\1'\"$PR_VERSION\"'|g; " + - "s|(Include=\"Aspire\\.[^\"]+\" Version=\")([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)(\")|\\1'\"$PR_VERSION\"'\\3|g' " + - $"{csprojRelativePath}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - // Show the updated csproj for the recording - await auto.TypeAsync($"echo '--- Updated csproj ---' && cat {csprojRelativePath}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - } - /// /// Clones a Git repository inside the container. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index bf8a1516031..e314136bd01 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -62,23 +62,21 @@ public async Task UpgradeAndRunAspireWithNodeSample() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Phase 1: Run aspire update to perform structural migration (SDK format, etc.) - // This uses the implicit channel and updates to the latest stable version. - await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", - timeout: TimeSpan.FromMinutes(5)); - - // Phase 2: In PullRequest mode, upgrade all Aspire package versions to the PR build. - // aspire update --channel has issues with dotnet package add and PR hive sources, - // so we do a direct version replacement and set up the NuGet.config for the hive. + // Run aspire update with the PR channel to upgrade directly to the PR build. + // In PullRequest mode, the PR hive is already installed at ~/.aspire/hives/pr-{N}. + // The --channel flag tells aspire update to use that hive for package resolution. + // aspire update handles NuGet.config creation/merging via its own interactive prompts. + string? channel = null; if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) { var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var channel = $"pr-{prNumber}"; - - await auto.SetupPrHiveNuGetConfigAsync(counter, channel); - await auto.UpgradeToPrVersionAsync(counter, channel, AppHostCsproj); + 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); From 9ddc8a903aa4a183babef7ebb3bceeb3a0bfb82b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 16:08:55 +1100 Subject: [PATCH 10/14] Use --channel with fallback sed for package references aspire update --channel pr-{N} correctly updates the SDK version and creates the NuGet.config, but the apply phase (dotnet package add) fails for individual PackageReference entries due to a known issue where --configfile is not passed to the underlying NuGet restore. After aspire update, read the csproj from the mounted volume, extract the PR version from the SDK attribute (which was set correctly), and use sed to fix any remaining package references still on old versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SampleUpgradeAspireWithNodeTests.cs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index e314136bd01..c11ee31889a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -62,10 +62,13 @@ public async Task UpgradeAndRunAspireWithNodeSample() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Run aspire update with the PR channel to upgrade directly to the PR build. + // Run aspire update with the PR channel to upgrade to the PR build. // In PullRequest mode, the PR hive is already installed at ~/.aspire/hives/pr-{N}. // The --channel flag tells aspire update to use that hive for package resolution. - // aspire update handles NuGet.config creation/merging via its own interactive prompts. + // Note: aspire update --channel correctly updates the SDK version and creates the + // NuGet.config, but has a known issue where the apply phase (dotnet package add) + // can fail for individual PackageReference entries. After the update we fix up any + // remaining old references. string? channel = null; if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) { @@ -77,6 +80,35 @@ await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", channel: channel, timeout: TimeSpan.FromMinutes(5)); + // In PR mode, fix up any package references that aspire update failed to apply. + // The SDK version is updated correctly but dotnet package add may fail because + // it doesn't pass --configfile to the underlying NuGet restore. + if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) + { + var csproj = await File.ReadAllTextAsync( + Path.Combine(workDir, SamplePath, AppHostCsproj)); + + // Extract the PR version from the SDK attribute that aspire update did set + var sdkMatch = System.Text.RegularExpressions.Regex.Match( + csproj, @"Aspire\.AppHost\.Sdk/([\d]+\.[\d]+\.[\d]+-pr\.\d+\.g[0-9a-f]+)"); + + if (sdkMatch.Success) + { + var prVersion = sdkMatch.Groups[1].Value; + output.WriteLine($"PR version from SDK: {prVersion}"); + + // Use sed to update any PackageReference entries still on old versions + await auto.TypeAsync( + $"sed -i -E " + + "'s|(Include=\"Aspire\\.[^\"]+\" Version=\")([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)(\")|\\1" + + prVersion + + "\\3|g' " + + AppHostCsproj); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + } + } + // 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); From 0bc5b326806f1f0bd14dc1e82b697ddb6abc51a5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 16:37:38 +1100 Subject: [PATCH 11/14] Fix aspire update --channel to pass NuGet config to dotnet package add When using an explicit channel (e.g., a PR hive), aspire update creates a NuGet.config with the channel's package source for the search phase, but the apply phase calls 'dotnet package add' without making that config discoverable. Since 'dotnet package add' doesn't support --configfile, use the NuGet config directory as the working directory (same pattern as InstallTemplateAsync). Changes: - DotNetCliRunner: Add AddPackageAsync overload with nugetConfigDirectory parameter; use it as working directory when provided - ProjectUpdater: Store _nugetConfigDirectory field during explicit channel config creation; pass it through to UpdatePackageReferenceInProject - Remove sed workaround from E2E test now that the fix is in place - Update test mocks to implement new interface method Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 15 ++++++++-- src/Aspire.Cli/Projects/ProjectUpdater.cs | 13 +++++++-- .../SampleUpgradeAspireWithNodeTests.cs | 29 ------------------- .../Templating/DotNetTemplateFactoryTests.cs | 3 ++ .../TestServices/TestDotNetCliRunner.cs | 5 ++++ 5 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 2f88d686563..e0141dc8ec7 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -34,6 +34,7 @@ internal interface IDotNetCliRunner Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -670,7 +671,11 @@ 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) + + public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => + AddPackageAsync(projectFilePath, packageName, packageVersion, nugetSource, noRestore, nugetConfigDirectory: null, options, cancellationToken); + + public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -713,11 +718,17 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN logger.LogInformation("Adding package {PackageName} with version {PackageVersion} to project {ProjectFilePath}", packageName, packageVersion, projectFilePath.FullName); + // When a NuGet config directory is provided (e.g., for explicit channel hive packages), + // use it as the working directory so that dotnet package add discovers the NuGet.config + // containing the channel's package source. This follows the same pattern as + // InstallTemplateAsync which uses the config directory as the working directory. + var workingDirectory = nugetConfigDirectory ?? projectFilePath.Directory!; + var result = await ExecuteAsync( args: cliArgs, env: null, projectFile: projectFilePath, - workingDirectory: projectFilePath.Directory!, + workingDirectory: workingDirectory, backchannelCompletionSource: null, options: options, cancellationToken: cancellationToken); diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 735d3ddbaff..365c369fd98 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -25,6 +25,9 @@ internal interface IProjectUpdater internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater { + // Set during UpdateProjectAsync for explicit channels (e.g., PR hives) so that + // dotnet package add can discover the channel's package source via NuGet.config. + private DirectoryInfo? _nugetConfigDirectory; public async Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default) { logger.LogDebug("Fetching '{AppHostPath}' items and properties.", projectFile.FullName); @@ -75,6 +78,11 @@ public async Task UpdateProjectAsync(FileInfo projectFile, return new ProjectUpdateResult { UpdatedApplied = false }; } + // Track the NuGet config directory so we can pass it to the apply phase. + // When using an explicit channel (e.g., a PR hive), the NuGet config contains + // the package source needed to resolve hive packages during dotnet package add. + _nugetConfigDirectory = null; + if (channel.Type == PackageChannelType.Explicit) { var (configPathsExitCode, configPaths) = await runner.GetNuGetConfigPathsAsync(projectFile.Directory!, new(), cancellationToken); @@ -114,8 +122,8 @@ public async Task UpdateProjectAsync(FileInfo projectFile, required: true, cancellationToken: cancellationToken); - var nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile); - await NuGetConfigMerger.CreateOrUpdateAsync(nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken); + _nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile); + await NuGetConfigMerger.CreateOrUpdateAsync(_nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken); } interactionService.DisplayEmptyLine(); @@ -883,6 +891,7 @@ private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPa packageVersion: package.Version, nugetSource: null, noRestore: false, + nugetConfigDirectory: _nugetConfigDirectory, options: new(), cancellationToken: cancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index c11ee31889a..ad9c1d07209 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -80,35 +80,6 @@ await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", channel: channel, timeout: TimeSpan.FromMinutes(5)); - // In PR mode, fix up any package references that aspire update failed to apply. - // The SDK version is updated correctly but dotnet package add may fail because - // it doesn't pass --configfile to the underlying NuGet restore. - if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) - { - var csproj = await File.ReadAllTextAsync( - Path.Combine(workDir, SamplePath, AppHostCsproj)); - - // Extract the PR version from the SDK attribute that aspire update did set - var sdkMatch = System.Text.RegularExpressions.Regex.Match( - csproj, @"Aspire\.AppHost\.Sdk/([\d]+\.[\d]+\.[\d]+-pr\.\d+\.g[0-9a-f]+)"); - - if (sdkMatch.Success) - { - var prVersion = sdkMatch.Groups[1].Value; - output.WriteLine($"PR version from SDK: {prVersion}"); - - // Use sed to update any PackageReference entries still on old versions - await auto.TypeAsync( - $"sed -i -E " + - "'s|(Include=\"Aspire\\.[^\"]+\" Version=\")([0-9]+\\.[0-9]+\\.[0-9]+[^\"]*)(\")|\\1" + - prVersion + - "\\3|g' " + - AppHostCsproj); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - } - } - // 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); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 3bf653368cc..c53abfd9311 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -513,6 +513,9 @@ public Task BuildAsync(FileInfo projectFile, bool noRestore, DotNetCliRunne public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + => throw new NotImplementedException(); + public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 32101c9ae3a..e17459d2bcc 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -32,6 +32,11 @@ public Task AddPackageAsync(FileInfo projectFilePath, string packageName, s : throw new NotImplementedException(); } + public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + return AddPackageAsync(projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken); + } + public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return AddProjectToSolutionAsyncCallback != null From 995e6bc11c02bcfd58440ba55db0e5714ba46324 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 16:59:33 +1100 Subject: [PATCH 12/14] Skip restore during individual package adds for explicit channels When using an explicit channel (e.g., a PR hive), the NuGet.config's package source mapping routes Aspire* packages exclusively to the hive source. During 'dotnet package add', NuGet does an implicit restore that fails because other Aspire packages still at old versions don't exist in the hive source. Fix by: 1. Setting --no-restore on each 'dotnet package add' when using an explicit channel to avoid restore failures from partial upgrades 2. Running a single 'dotnet restore' after all packages are updated, when all versions are consistent Also default TestDotNetCliRunner.RestoreAsync to succeed when no callback is set, matching the pattern used by GetNuGetConfigPathsAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectUpdater.cs | 27 ++++++++++++++++++- .../UpdateCommandStrings.Designer.cs | 2 ++ .../Resources/UpdateCommandStrings.resx | 6 +++++ .../Resources/xlf/UpdateCommandStrings.cs.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.de.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.es.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.fr.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.it.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.ja.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.ko.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.pl.xlf | 10 +++++++ .../xlf/UpdateCommandStrings.pt-BR.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.ru.xlf | 10 +++++++ .../Resources/xlf/UpdateCommandStrings.tr.xlf | 10 +++++++ .../xlf/UpdateCommandStrings.zh-Hans.xlf | 10 +++++++ .../xlf/UpdateCommandStrings.zh-Hant.xlf | 10 +++++++ .../TestServices/TestDotNetCliRunner.cs | 2 +- 17 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 365c369fd98..ac348908e6a 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -141,6 +141,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 (_nugetConfigDirectory is not null) + { + 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); @@ -885,12 +903,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 = _nugetConfigDirectory is not null; + var exitCode = await runner.AddPackageAsync( projectFilePath: projectFile, packageName: package.Id, packageVersion: package.Version, nugetSource: null, - noRestore: false, + noRestore: noRestore, nugetConfigDirectory: _nugetConfigDirectory, 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.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index e17459d2bcc..1af48eea382 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -55,7 +55,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) From 7fe1be6bfaed17f621a455825ed7b68302d54155 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 18:06:37 +1100 Subject: [PATCH 13/14] Add Playwright dashboard verification and host networking to sample upgrade tests - Add useHostNetwork parameter to CreateDockerTestTerminal (sets c.Network = "host") - Modify AspireRunSampleAsync to capture and return dashboard URL from terminal output - Add DashboardVerificationHelpers with Playwright-based dashboard verification - Add PollEndpointAsync for host-side HTTP endpoint polling with retry - Add Microsoft.Playwright package reference for browser-based dashboard testing - Update SampleUpgradeAspireWithNodeTests to verify dashboard shows expected resources - Dashboard screenshots saved to testresults/screenshots/ for CI artifact upload - Update CLI E2E testing skill with sample upgrade test documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/cli-e2e-testing/SKILL.md | 204 +++++++++++++++++ .../Aspire.Cli.EndToEnd.Tests.csproj | 1 + .../Helpers/CliE2ETestHelpers.cs | 10 + .../Helpers/DashboardVerificationHelpers.cs | 214 ++++++++++++++++++ .../Helpers/SampleUpgradeHelpers.cs | 33 ++- .../SampleUpgradeAspireWithNodeTests.cs | 46 +++- 6 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/DashboardVerificationHelpers.cs 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/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 index 79602c5f0fd..1731867a37f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SampleUpgradeHelpers.cs @@ -1,11 +1,18 @@ // 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), @@ -17,6 +24,10 @@ 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. /// @@ -172,12 +183,13 @@ await auto.WaitUntilAsync(snapshot => /// /// Runs aspire run on a sample and waits for the apphost to start. - /// Returns when the "Press CTRL+C to stop the apphost and exit." message is displayed. + /// 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. - internal static async Task AspireRunSampleAsync( + /// An containing the dashboard login URL. + internal static async Task AspireRunSampleAsync( this Hex1bTerminalAutomator auto, string? appHostRelativePath = null, TimeSpan? startTimeout = null) @@ -191,7 +203,9 @@ internal static async Task AspireRunSampleAsync( await auto.TypeAsync(command); await auto.EnterAsync(); - // Wait for the apphost to start successfully + 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) @@ -202,8 +216,21 @@ await auto.WaitUntilAsync(s => "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); } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs index ad9c1d07209..d87b36691ed 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SampleUpgradeAspireWithNodeTests.cs @@ -19,6 +19,11 @@ public sealed class SampleUpgradeAspireWithNodeTests(ITestOutputHelper output) 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() { @@ -33,9 +38,12 @@ public async Task UpgradeAndRunAspireWithNodeSample() 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}"]); @@ -63,12 +71,6 @@ public async Task UpgradeAndRunAspireWithNodeSample() await auto.WaitForSuccessPromptAsync(counter); // Run aspire update with the PR channel to upgrade to the PR build. - // In PullRequest mode, the PR hive is already installed at ~/.aspire/hives/pr-{N}. - // The --channel flag tells aspire update to use that hive for package resolution. - // Note: aspire update --channel correctly updates the SDK version and creates the - // NuGet.config, but has a known issue where the apply phase (dotnet package add) - // can fail for individual PackageReference entries. After the update we fix up any - // remaining old references. string? channel = null; if (installMode == CliE2ETestHelpers.DockerInstallMode.PullRequest) { @@ -93,11 +95,39 @@ await auto.AspireUpdateInSampleAsync(counter, samplePath: ".", Assert.Contains("-pr.", csprojContent); } - // Run the sample - await auto.AspireRunSampleAsync( + // 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); From dc2a7b6b87f548c727b615267e953499b060f2c2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Mar 2026 19:25:36 +1100 Subject: [PATCH 14/14] Simplify CLI changes: remove AddPackageAsync overload, use --no-restore only The NuGetConfigMerger already creates the NuGet.config in a discoverable ancestor directory, so the working directory change on AddPackageAsync was unnecessary. The only real bug was the implicit restore conflict from package source mappings during dotnet package add for explicit channels. Simplified to: - _isExplicitChannel boolean flag (no DirectoryInfo field) - Pass noRestore: true for explicit channels - Final dotnet restore after all packages updated - No IDotNetCliRunner interface changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 14 ++----------- src/Aspire.Cli/Projects/ProjectUpdater.cs | 21 ++++++++----------- .../Templating/DotNetTemplateFactoryTests.cs | 3 --- .../TestServices/TestDotNetCliRunner.cs | 5 ----- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index e0141dc8ec7..6f55ff86784 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -34,7 +34,6 @@ internal interface IDotNetCliRunner Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -672,10 +671,7 @@ public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotN cancellationToken: cancellationToken); } - public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => - AddPackageAsync(projectFilePath, packageName, packageVersion, nugetSource, noRestore, nugetConfigDirectory: null, options, cancellationToken); - - public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions 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(); @@ -718,17 +714,11 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN logger.LogInformation("Adding package {PackageName} with version {PackageVersion} to project {ProjectFilePath}", packageName, packageVersion, projectFilePath.FullName); - // When a NuGet config directory is provided (e.g., for explicit channel hive packages), - // use it as the working directory so that dotnet package add discovers the NuGet.config - // containing the channel's package source. This follows the same pattern as - // InstallTemplateAsync which uses the config directory as the working directory. - var workingDirectory = nugetConfigDirectory ?? projectFilePath.Directory!; - var result = await ExecuteAsync( args: cliArgs, env: null, projectFile: projectFilePath, - workingDirectory: workingDirectory, + workingDirectory: projectFilePath.Directory!, backchannelCompletionSource: null, options: options, cancellationToken: cancellationToken); diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index ac348908e6a..9b40d51164b 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -25,9 +25,7 @@ internal interface IProjectUpdater internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater { - // Set during UpdateProjectAsync for explicit channels (e.g., PR hives) so that - // dotnet package add can discover the channel's package source via NuGet.config. - private DirectoryInfo? _nugetConfigDirectory; + private bool _isExplicitChannel; public async Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default) { logger.LogDebug("Fetching '{AppHostPath}' items and properties.", projectFile.FullName); @@ -78,10 +76,9 @@ public async Task UpdateProjectAsync(FileInfo projectFile, return new ProjectUpdateResult { UpdatedApplied = false }; } - // Track the NuGet config directory so we can pass it to the apply phase. - // When using an explicit channel (e.g., a PR hive), the NuGet config contains - // the package source needed to resolve hive packages during dotnet package add. - _nugetConfigDirectory = null; + // 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) { @@ -122,8 +119,9 @@ public async Task UpdateProjectAsync(FileInfo projectFile, required: true, cancellationToken: cancellationToken); - _nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile); - await NuGetConfigMerger.CreateOrUpdateAsync(_nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken); + _isExplicitChannel = true; + var nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile); + await NuGetConfigMerger.CreateOrUpdateAsync(nugetConfigDirectory, channel, AnalyzeAndConfirmNuGetConfigChanges, cancellationToken); } interactionService.DisplayEmptyLine(); @@ -143,7 +141,7 @@ await interactionService.ShowStatusAsync( // When using an explicit channel with --no-restore on individual package adds, // perform a final restore to ensure all packages resolve correctly together. - if (_nugetConfigDirectory is not null) + if (_isExplicitChannel) { await interactionService.ShowStatusAsync( UpdateCommandStrings.RestoringPackages, @@ -908,7 +906,7 @@ private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPa // 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 = _nugetConfigDirectory is not null; + var noRestore = _isExplicitChannel; var exitCode = await runner.AddPackageAsync( projectFilePath: projectFile, @@ -916,7 +914,6 @@ private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPa packageVersion: package.Version, nugetSource: null, noRestore: noRestore, - nugetConfigDirectory: _nugetConfigDirectory, options: new(), cancellationToken: cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index c53abfd9311..3bf653368cc 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -513,9 +513,6 @@ public Task BuildAsync(FileInfo projectFile, bool noRestore, DotNetCliRunne public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - => throw new NotImplementedException(); - public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 1af48eea382..38ca2183225 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -32,11 +32,6 @@ public Task AddPackageAsync(FileInfo projectFilePath, string packageName, s : throw new NotImplementedException(); } - public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, DirectoryInfo? nugetConfigDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - { - return AddPackageAsync(projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken); - } - public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return AddProjectToSolutionAsyncCallback != null