From a10e9fa97535e34bb13ed9a5670becb3e0defc04 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 7 Apr 2026 19:27:06 -0400 Subject: [PATCH 01/11] Self-contained dogfood installs under ~/.aspire/dogfood/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move PR and local-dev installs to a flat layout under ~/.aspire/dogfood/{name}/ so each dogfood install is fully self-contained (CLI + bundle + hives) and can be cleaned up with a single directory delete. - Program.cs: add GetInstallRootDirectory() that resolves the install root from Environment.ProcessPath (bin/ layout → parent dir; flat layout → CLI dir; fallback → ~/.aspire) - BundleService: align GetDefaultExtractDir with the same rule - Scripts: get-aspire-cli-pr.sh/.ps1 and localhive.sh/.ps1 install to ~/.aspire/dogfood/{name}/ and no longer set the global channel - Docs: update dogfooding-pull-requests.md for new layout - Tests: cover both stable and dogfood layouts Fixes #15935 --- docs/dogfooding-pull-requests.md | 20 ++++--- eng/scripts/get-aspire-cli-pr.ps1 | 62 +++++++++----------- eng/scripts/get-aspire-cli-pr.sh | 57 ++++++++---------- localhive.ps1 | 24 ++++---- localhive.sh | 26 ++++---- src/Aspire.Cli/Bundles/BundleService.cs | 13 +++- src/Aspire.Cli/Program.cs | 36 +++++++++++- tests/Aspire.Cli.Tests/BundleServiceTests.cs | 17 +++++- tests/Aspire.Cli.Tests/ProgramTests.cs | 12 ++++ 9 files changed, 158 insertions(+), 109 deletions(-) diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index 8ef28152def..febabdac85a 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -28,14 +28,14 @@ Notes: ## What gets installed - Aspire CLI: - - Default location: `~/.aspire/bin/aspire` (or `aspire.exe` on Windows) - - Important: If you already have the Aspire CLI installed under the same prefix (default `~/.aspire`), running this script will overwrite that installation. To switch back to the official build, simply re-run the standard Aspire CLI install script referenced in the README to reinstall the released version. + - Default location: `~/.aspire/dogfood/pr-/aspire` (or `aspire.exe` on Windows) + - Important: PR installs are self-contained under `~/.aspire/dogfood/pr-/` and do not overwrite the stable CLI at `~/.aspire/bin/aspire`. Each PR has its own isolated install root. - PR-scoped NuGet packages "hive": - - Default location: `~/.aspire/hives/pr-/packages` + - Default location: `~/.aspire/dogfood/pr-/hives/pr-/packages` - This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. -The scripts attempt to add `~/.aspire/bin` to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. +The scripts attempt to add `~/.aspire/dogfood/pr-` to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. ## Quickstart @@ -166,15 +166,17 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` ## Uninstall/Cleanup -- Remove the CLI: - - Delete `~/.aspire/bin/aspire` (or the custom install path you used) +- Remove a PR dogfood install (CLI, bundle, hives — everything): + - Delete the entire install root: `rm -rf ~/.aspire/dogfood/pr-` (or the custom install path you used) - Remove the PATH entry from your shell profile if added -- Remove PR-specific packages: - - Delete `~/.aspire/hives/pr-/packages` +- Remove all dogfood installs: + - `rm -rf ~/.aspire/dogfood` + +- The stable CLI at `~/.aspire/bin/aspire` is not affected by dogfood installs. ## Safety note Remote one-liners execute scripts fetched from the repository. Review the script source before running if needed: - Bash: `eng/scripts/get-aspire-cli-pr.sh` -- PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` \ No newline at end of file +- PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 98ed830b042..5b51381bf7c 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -21,8 +21,8 @@ .PARAMETER InstallPath Directory prefix to install (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) - CLI will be installed to InstallPath\bin (or InstallPath/bin on Unix) - NuGet packages will be installed to InstallPath\hives\pr-PRNUMBER\packages + CLI will be installed to InstallPath\dogfood\pr-PRNUMBER (or InstallPath/dogfood/pr-PRNUMBER on Unix) + NuGet packages will be installed to InstallPath\dogfood\pr-PRNUMBER\hives\pr-PRNUMBER\packages .PARAMETER OS Override OS detection (win, linux, linux-musl, osx) @@ -392,11 +392,11 @@ function Backup-ExistingCliExecutable { [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + if (Test-Path $TargetExePath) { $unixTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $backupPath = "$TargetExePath.old.$unixTimestamp" - + if ($PSCmdlet.ShouldProcess($TargetExePath, "Backup to $backupPath")) { Write-Message "Backing up existing CLI: $TargetExePath -> $backupPath" -Level Verbose @@ -412,7 +412,7 @@ function Backup-ExistingCliExecutable { return $backupPath } } - + return $null } @@ -422,18 +422,18 @@ function Restore-CliExecutableFromBackup { param( [Parameter(Mandatory = $true)] [string]$BackupPath, - + [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + if ($PSCmdlet.ShouldProcess($BackupPath, "Restore to $TargetExePath")) { Write-Message "Restoring CLI from backup: $BackupPath -> $TargetExePath" -Level Warning - + if (Test-Path $TargetExePath) { Remove-Item -Path $TargetExePath -Force -ErrorAction SilentlyContinue } - + Move-Item -Path $BackupPath -Destination $TargetExePath -Force -ErrorAction Stop } } @@ -445,15 +445,15 @@ function Remove-OldCliBackupFiles { [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + $directory = Split-Path -Parent $TargetExePath if ([string]::IsNullOrEmpty($directory)) { return } - + $exeName = Split-Path -Leaf $TargetExePath $searchPattern = "$exeName.old.*" - + $oldBackupFiles = Get-ChildItem -Path $directory -Filter $searchPattern -ErrorAction SilentlyContinue foreach ($backupFile in $oldBackupFiles) { if ($PSCmdlet.ShouldProcess($backupFile.FullName, "Delete old backup")) { @@ -731,17 +731,17 @@ function Save-GlobalSettings { param( [Parameter(Mandatory = $true)] [string]$CliPath, - + [Parameter(Mandatory = $true)] [string]$Key, - + [Parameter(Mandatory = $true)] [string]$Value ) - + if ($PSCmdlet.ShouldProcess("$Key = $Value", "Set global config via aspire CLI")) { Write-Message "Setting global config: $Key = $Value" -Level Verbose - + $output = & $CliPath config set -g $Key $Value 2>&1 if ($LASTEXITCODE -ne 0) { Write-Message "Failed to set global config via aspire CLI" -Level Warning @@ -874,32 +874,32 @@ function Get-VersionSuffixFromPackages { [Parameter(Mandatory = $true)] [string]$DownloadDir ) - + if ($PSCmdlet.ShouldProcess("packages", "Extract version suffix from packages") -and $WhatIfPreference) { # Return a mock version for WhatIf return "pr.1234.a1b2c3d4" } - + # Look for any .nupkg file and extract version from its name $nupkgFiles = Get-ChildItem -Path $DownloadDir -Filter "*.nupkg" -Recurse | Select-Object -First 1 - + if (-not $nupkgFiles) { Write-Message "No .nupkg files found to extract version from" -Level Verbose throw "No NuGet packages found to extract version information from" } - + $filename = $nupkgFiles.Name Write-Message "Extracting version from package: $filename" -Level Verbose - + # Extract version from package name using a more robust approach # Remove .nupkg extension first, then look for the specific version pattern $baseName = $filename -replace '\.nupkg$', '' - + # Look for semantic version pattern with PR suffix (more specific and robust) if ($baseName -match '.*\.(\d+\.\d+\.\d+-pr\.\d+\.[0-9a-g]+)$') { $version = $Matches[1] Write-Message "Extracted version: $version" -Level Verbose - + # Extract just the PR suffix part using more specific regex if ($version -match '(pr\.[0-9]+\.[0-9a-g]+)') { $versionSuffix = $Matches[1] @@ -1210,9 +1210,9 @@ function Start-DownloadAndInstall { Write-Message "Using workflow run https://github.com/$Script:Repository/actions/runs/$runId" -Level Info - # Set installation paths - $cliBinDir = Join-Path $resolvedInstallPrefix "bin" - $nugetHiveDir = Join-Path $resolvedInstallPrefix "hives" "pr-$PRNumber" "packages" + # Set installation paths (self-contained dogfood layout) + $cliBinDir = Join-Path $resolvedInstallPrefix "dogfood" "pr-$PRNumber" + $nugetHiveDir = Join-Path $resolvedInstallPrefix "dogfood" "pr-$PRNumber" "hives" "pr-$PRNumber" "packages" $rid = Get-RuntimeIdentifier $OS $Architecture @@ -1257,14 +1257,8 @@ function Start-DownloadAndInstall { } } - # Save the global channel setting to the PR hive channel - # This allows 'aspire new' and 'aspire init' to use the same channel by default - if (-not $HiveOnly) { - # Determine CLI path - $cliExe = if ($Script:HostOS -eq "win") { "aspire.exe" } else { "aspire" } - $cliPath = Join-Path $cliBinDir $cliExe - Save-GlobalSettings -CliPath $cliPath -Key "channel" -Value "pr-$PRNumber" - } + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Update PATH environment variables if (-not $HiveOnly) { diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 3500a6ddcd4..85ddec84b46 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -61,8 +61,8 @@ USAGE: PR_NUMBER Pull request number (required) --run-id, -r WORKFLOW_ID Workflow run ID to download from (optional) -i, --install-path PATH Directory prefix to install (default: ~/.aspire) - CLI installs to: /bin - NuGet hive: /hives/pr-/packages + CLI installs to: /dogfood/pr- + NuGet hive: /dogfood/pr-/hives/pr-/packages --os OS Override OS detection (win, linux, linux-musl, osx) --arch ARCH Override architecture detection (x64, arm64) --hive-only Only install NuGet packages to the hive, skip CLI download @@ -422,19 +422,19 @@ save_global_settings() { local cli_path="$1" local key="$2" local value="$3" - + if [[ "$DRY_RUN" == true ]]; then say_info "[DRY RUN] Would run: $cli_path config set -g $key $value" return 0 fi - + say_verbose "Setting global config: $key = $value" - + if ! "$cli_path" config set -g "$key" "$value" 2>/dev/null; then say_warn "Failed to set global config via aspire CLI" return 1 fi - + say_verbose "Global config saved: $key = $value" } @@ -631,41 +631,41 @@ get_pr_head_sha() { # Function to extract version suffix from downloaded NuGet packages extract_version_suffix_from_packages() { local download_dir="$1" - + if [[ "$DRY_RUN" == true ]]; then # Return a mock version for dry run printf "pr.1234.a1b2c3d4" return 0 fi - + # Look for any .nupkg file and extract version from its name local nupkg_file nupkg_file=$(find "$download_dir" -name "*.nupkg" | head -1) - + if [[ -z "$nupkg_file" ]]; then say_verbose "No .nupkg files found to extract version from" return 1 fi - + local filename filename=$(basename "$nupkg_file") say_verbose "Extracting version from package: $filename" - + # Extract version from package name using a more robust two-step approach # First remove the .nupkg extension, then extract the version part local base_name="${filename%.nupkg}" local version - + # Look for semantic version pattern with PR suffix (more specific and robust) version=$(echo "$base_name" | sed -En 's/.*\.([0-9]+\.[0-9]+\.[0-9]+-pr\.[0-9]+\.[a-g0-9]+)/\1/p') - + if [[ -z "$version" ]]; then say_verbose "Could not extract version from package name: $filename" return 1 fi - + say_verbose "Extracted full version: $version" - + # Extract just the PR suffix part using bash regex for better compatibility if [[ "$version" =~ (pr\.[0-9]+\.[a-g0-9]+) ]]; then local version_suffix="${BASH_REMATCH[1]}" @@ -972,9 +972,9 @@ download_and_install_from_pr() { say_info "Using workflow run https://github.com/${REPO}/actions/runs/$workflow_run_id" - # Set installation paths - local cli_install_dir="$INSTALL_PREFIX/bin" - local nuget_hive_dir="$INSTALL_PREFIX/hives/pr-$PR_NUMBER/packages" + # Set installation paths (self-contained dogfood layout) + local cli_install_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER" + local nuget_hive_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER/hives/pr-$PR_NUMBER/packages" # First, download both artifacts local cli_archive_path nuget_download_dir @@ -1039,19 +1039,8 @@ download_and_install_from_pr() { fi fi - # Save the global channel setting to the PR hive channel - # This allows 'aspire new' and 'aspire init' to use the same channel by default - if [[ "$HIVE_ONLY" != true ]]; then - # Determine CLI path - local cli_path - if [[ -f "$cli_install_dir/aspire.exe" ]]; then - cli_path="$cli_install_dir/aspire.exe" - else - cli_path="$cli_install_dir/aspire" - fi - # Non-fatal: channel can be set manually if this fails - save_global_settings "$cli_path" "channel" "pr-$PR_NUMBER" || true - fi + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. } # ============================================================================= @@ -1084,9 +1073,9 @@ else INSTALL_PREFIX_UNEXPANDED="$INSTALL_PREFIX" fi -# Set paths based on install prefix -cli_install_dir="$INSTALL_PREFIX/bin" -INSTALL_PATH_UNEXPANDED="$INSTALL_PREFIX_UNEXPANDED/bin" +# Set paths based on install prefix (self-contained dogfood layout) +cli_install_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER" +INSTALL_PATH_UNEXPANDED="$INSTALL_PREFIX_UNEXPANDED/dogfood/pr-$PR_NUMBER" # Create a temporary directory for downloads if [[ "$DRY_RUN" == true ]]; then diff --git a/localhive.ps1 b/localhive.ps1 index b5f9693280a..97d35a6a5a7 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -206,7 +206,7 @@ if (-not $packages -or $packages.Count -eq 0) { } Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) -$hivesRoot = Join-Path (Join-Path $HOME '.aspire') 'hives' +$hivesRoot = Join-Path (Join-Path (Join-Path (Join-Path $HOME '.aspire') 'dogfood') $Name) 'hives' $hiveRoot = Join-Path $hivesRoot $Name $hivePath = Join-Path $hiveRoot 'packages' @@ -262,7 +262,8 @@ if ($IsWindows) { } $aspireRoot = Join-Path $HOME '.aspire' -$cliBinDir = Join-Path $aspireRoot 'bin' +$dogfoodRoot = Join-Path $aspireRoot 'dogfood' $Name +$cliBinDir = $dogfoodRoot # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if (-not $SkipBundle) { @@ -287,10 +288,10 @@ if (-not $SkipBundle) { exit 1 } - # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + # Copy managed/ and dcp/ to dogfood install root so the CLI auto-discovers them foreach ($component in @('managed', 'dcp')) { $sourceDir = Join-Path $bundleLayoutDir $component - $destDir = Join-Path $aspireRoot $component + $destDir = Join-Path $dogfoodRoot $component if (Test-Path -LiteralPath $sourceDir) { if (Test-Path -LiteralPath $destDir) { Remove-Item -LiteralPath $destDir -Force -Recurse @@ -302,10 +303,10 @@ if (-not $SkipBundle) { } } - Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" + Write-Log "Bundle installed to $dogfoodRoot (managed/ + dcp/)" } -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to the dogfood root if (-not $SkipCli) { $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } @@ -377,9 +378,8 @@ if (-not $SkipCli) { $installedCliPath = Join-Path $cliBinDir $cliExeName Write-Log "Aspire CLI installed to: $installedCliPath" - # Set the channel to the local hive so templates and packages resolve from it - & $installedCliPath config set channel $Name -g 2>$null - Write-Log "Set global channel to '$Name'" + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Check if the bin directory is in PATH $pathSeparator = [System.IO.Path]::PathSeparator @@ -404,12 +404,12 @@ Write-Host Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." Write-Host if (-not $SkipCli) { - Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" + Write-Log "The locally-built CLI was installed to: $dogfoodRoot" Write-Host } if (-not $SkipBundle) { - Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" - Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Log "Bundle (aspire-managed + DCP) installed to: $dogfoodRoot" + Write-Log " The CLI at $dogfoodRoot/ will auto-discover managed/ and dcp/ in the same directory." Write-Host } Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 859865339ac..cfb4d2d4815 100755 --- a/localhive.sh +++ b/localhive.sh @@ -192,7 +192,7 @@ if [[ $pkg_count -eq 0 ]]; then fi log "Found $pkg_count packages in $PKG_DIR" -HIVES_ROOT="$HOME/.aspire/hives" +HIVES_ROOT="$HOME/.aspire/dogfood/$HIVE_NAME/hives" HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" HIVE_PATH="$HIVE_ROOT/packages" @@ -238,7 +238,8 @@ case "$(uname -s)" in esac ASPIRE_ROOT="$HOME/.aspire" -CLI_BIN_DIR="$ASPIRE_ROOT/bin" +DOGFOOD_ROOT="$ASPIRE_ROOT/dogfood/$HIVE_NAME" +CLI_BIN_DIR="$DOGFOOD_ROOT" # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if [[ $SKIP_BUNDLE -eq 0 ]]; then @@ -263,10 +264,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then exit 1 fi - # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + # Copy managed/ and dcp/ to dogfood install root so the CLI auto-discovers them for component in managed dcp; do SOURCE_DIR="$BUNDLE_LAYOUT_DIR/$component" - DEST_DIR="$ASPIRE_ROOT/$component" + DEST_DIR="$DOGFOOD_ROOT/$component" if [[ -d "$SOURCE_DIR" ]]; then rm -rf "$DEST_DIR" log "Copying $component/ to $DEST_DIR" @@ -282,10 +283,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then fi done - log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" + log "Bundle installed to $DOGFOOD_ROOT (managed/ + dcp/)" fi -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to the dogfood root if [[ $SKIP_CLI -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then # Native AOT CLI from Bundle.proj publish @@ -319,11 +320,8 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" - if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then - log "Set global channel to '$HIVE_NAME'" - else - warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" - fi + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Check if the bin directory is in PATH if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then @@ -345,12 +343,12 @@ echo log "Channel behavior: Aspire* comes from the hive; others from nuget.org." echo if [[ $SKIP_CLI -eq 0 ]]; then - log "The locally-built CLI was installed to: $HOME/.aspire/bin" + log "The locally-built CLI was installed to: $DOGFOOD_ROOT" echo fi if [[ $SKIP_BUNDLE -eq 0 ]]; then - log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" - log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + log "Bundle (aspire-managed + DCP) installed to: $DOGFOOD_ROOT" + log " The CLI at $DOGFOOD_ROOT/ will auto-discover managed/ and dcp/ in the same directory." echo fi log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index c06ff55b5b1..15d16e0b247 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -156,8 +156,8 @@ private async Task ExtractCoreAsync(string destinationPath, /// /// Determines the default extraction directory for the current CLI binary. - /// If CLI is at ~/.aspire/bin/aspire, returns ~/.aspire/ so layout discovery - /// finds components via the bin/ layout pattern. + /// For stable layout (CLI in bin/), returns the parent directory. + /// For flat/dogfood layout (CLI directly in dir), returns that directory. /// internal static string? GetDefaultExtractDir(string processPath) { @@ -167,7 +167,14 @@ private async Task ExtractCoreAsync(string destinationPath, return null; } - return Path.GetDirectoryName(cliDir) ?? cliDir; + var cliDirectoryInfo = new DirectoryInfo(cliDir); + if (string.Equals(cliDirectoryInfo.Name, "bin", StringComparison.OrdinalIgnoreCase) && + cliDirectoryInfo.Parent is not null) + { + return cliDirectoryInfo.Parent.FullName; + } + + return cliDirectoryInfo.FullName; } /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 2bcdfbf53e6..f8cb265dfc3 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -512,10 +512,42 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu } private static DirectoryInfo GetHivesDirectory() + { + var installRoot = GetInstallRootDirectory(); + return new DirectoryInfo(Path.Combine(installRoot.FullName, "hives")); + } + + internal static DirectoryInfo GetInstallRootDirectory() { var homeDirectory = GetUsersAspirePath(); - var hivesDirectory = Path.Combine(homeDirectory, "hives"); - return new DirectoryInfo(hivesDirectory); + var processPath = Environment.ProcessPath; + + if (string.IsNullOrEmpty(processPath)) + { + return new DirectoryInfo(homeDirectory); + } + + var fileName = Path.GetFileName(processPath); + if (!string.Equals(fileName, "aspire", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fileName, "aspire.exe", StringComparison.OrdinalIgnoreCase)) + { + return new DirectoryInfo(homeDirectory); + } + + var cliDir = Path.GetDirectoryName(processPath); + if (string.IsNullOrEmpty(cliDir)) + { + return new DirectoryInfo(homeDirectory); + } + + var cliDirectoryInfo = new DirectoryInfo(cliDir); + if (string.Equals(cliDirectoryInfo.Name, "bin", StringComparison.OrdinalIgnoreCase) && + cliDirectoryInfo.Parent is not null) + { + return cliDirectoryInfo.Parent; + } + + return cliDirectoryInfo; } private static DirectoryInfo GetSdksDirectory() diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index 9d8f5f015f5..c0ff1cba530 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -53,7 +53,7 @@ public void VersionMarker_ReturnsNull_WhenMissing() } [Fact] - public void GetDefaultExtractDir_ReturnsParentOfParent() + public void GetDefaultExtractDir_StableLayout_ReturnsParentOfBinDir() { if (OperatingSystem.IsWindows()) { @@ -67,6 +67,21 @@ public void GetDefaultExtractDir_ReturnsParentOfParent() } } + [Fact] + public void GetDefaultExtractDir_DogfoodFlatLayout_ReturnsCliDirectory() + { + if (OperatingSystem.IsWindows()) + { + var result = BundleService.GetDefaultExtractDir(@"C:\Users\test\.aspire\dogfood\pr-1234\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire\dogfood\pr-1234", result); + } + else + { + var result = BundleService.GetDefaultExtractDir("/home/test/.aspire/dogfood/pr-1234/aspire"); + Assert.Equal("/home/test/.aspire/dogfood/pr-1234", result); + } + } + [Fact] public void GetCurrentVersion_ReturnsNonNull() { diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs index bcaa4d6b7fa..436829942db 100644 --- a/tests/Aspire.Cli.Tests/ProgramTests.cs +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Utils; + namespace Aspire.Cli.Tests; public class ProgramTests @@ -28,4 +30,14 @@ public void ParseLogFileOption_IgnoresValue_WhenOptionAppearsAfterDelimiter() Assert.Null(result); } + + [Fact] + public void GetInstallRootDirectory_FallsBackToHome_WhenProcessIsNotAspireCli() + { + // The test runner is not named "aspire", so it should fall back to ~/.aspire + var result = Program.GetInstallRootDirectory(); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } } From f8c9fb0d9f7dea2a1d47ceebd3d0353c699b2b6b Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 7 Apr 2026 20:08:12 -0400 Subject: [PATCH 02/11] Align resolvers, remove dead code, add regression tests - Add filename guard to BundleService.GetDefaultExtractDir so it returns null for non-aspire binaries, matching Program.GetInstallRootDirectory - Make GetInstallRootDirectory accept an optional processPath parameter for direct unit testing of all resolver code paths - Remove dead save_global_settings / Save-GlobalSettings functions from get-aspire-cli-pr.sh and get-aspire-cli-pr.ps1 - Add 6 regression tests covering stable layout, flat layout, null path, and non-aspire process fallback for both resolvers --- eng/scripts/get-aspire-cli-pr.ps1 | 31 ------------- eng/scripts/get-aspire-cli-pr.sh | 30 ------------ src/Aspire.Cli/Bundles/BundleService.cs | 7 +++ src/Aspire.Cli/Program.cs | 6 ++- tests/Aspire.Cli.Tests/BundleServiceTests.cs | 10 ++++ tests/Aspire.Cli.Tests/ProgramTests.cs | 48 ++++++++++++++++++++ 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 5b51381bf7c..028e28445d2 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -720,37 +720,6 @@ function Remove-TempDirectory { # END: Shared code # ============================================================================= -# Function to save global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -function Save-GlobalSettings { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string]$CliPath, - - [Parameter(Mandatory = $true)] - [string]$Key, - - [Parameter(Mandatory = $true)] - [string]$Value - ) - - if ($PSCmdlet.ShouldProcess("$Key = $Value", "Set global config via aspire CLI")) { - Write-Message "Setting global config: $Key = $Value" -Level Verbose - - $output = & $CliPath config set -g $Key $Value 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Message "Failed to set global config via aspire CLI" -Level Warning - return - } - Write-Message "Global config saved: $Key = $Value" -Level Verbose - } -} - # Function to check if gh command is available function Test-GitHubCLIDependency { [CmdletBinding()] diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 85ddec84b46..85c0fb97def 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -408,36 +408,6 @@ install_archive() { say_verbose "Successfully installed archive" } -# Function to save global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Parameters: -# $1 - cli_path: Path to the aspire CLI executable -# $2 - key: The configuration key to set -# $3 - value: The value to set -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -save_global_settings() { - local cli_path="$1" - local key="$2" - local value="$3" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would run: $cli_path config set -g $key $value" - return 0 - fi - - say_verbose "Setting global config: $key = $value" - - if ! "$cli_path" config set -g "$key" "$value" 2>/dev/null; then - say_warn "Failed to set global config via aspire CLI" - return 1 - fi - - say_verbose "Global config saved: $key = $value" -} - # Function to add PATH to shell configuration file # Parameters: # $1 - config_file: Path to the shell configuration file diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 15d16e0b247..72471bcd5d0 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -167,6 +167,13 @@ private async Task ExtractCoreAsync(string destinationPath, return null; } + var fileName = Path.GetFileName(processPath); + if (!string.Equals(fileName, "aspire", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fileName, "aspire.exe", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + var cliDirectoryInfo = new DirectoryInfo(cliDir); if (string.Equals(cliDirectoryInfo.Name, "bin", StringComparison.OrdinalIgnoreCase) && cliDirectoryInfo.Parent is not null) diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index f8cb265dfc3..db2c044a487 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -518,9 +518,13 @@ private static DirectoryInfo GetHivesDirectory() } internal static DirectoryInfo GetInstallRootDirectory() + { + return GetInstallRootDirectory(Environment.ProcessPath); + } + + internal static DirectoryInfo GetInstallRootDirectory(string? processPath) { var homeDirectory = GetUsersAspirePath(); - var processPath = Environment.ProcessPath; if (string.IsNullOrEmpty(processPath)) { diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index c0ff1cba530..2a3c71bc2f1 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -82,6 +82,16 @@ public void GetDefaultExtractDir_DogfoodFlatLayout_ReturnsCliDirectory() } } + [Fact] + public void GetDefaultExtractDir_ReturnsNull_WhenProcessIsNotAspireCli() + { + // Regression: without the filename guard, this would return a path instead of null. + // Both resolvers (Program.GetInstallRootDirectory and BundleService.GetDefaultExtractDir) + // must agree that non-aspire processes are not valid install roots. + var result = BundleService.GetDefaultExtractDir("/usr/local/share/dotnet/dotnet"); + Assert.Null(result); + } + [Fact] public void GetCurrentVersion_ReturnsNonNull() { diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs index 436829942db..ed21bc47bfd 100644 --- a/tests/Aspire.Cli.Tests/ProgramTests.cs +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -40,4 +40,52 @@ public void GetInstallRootDirectory_FallsBackToHome_WhenProcessIsNotAspireCli() Assert.Equal(expected, result.FullName); } + + [Fact] + public void GetInstallRootDirectory_StableLayout_ReturnsParentOfBinDir() + { + if (OperatingSystem.IsWindows()) + { + var result = Program.GetInstallRootDirectory(@"C:\Users\test\.aspire\bin\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire", result.FullName); + } + else + { + var result = Program.GetInstallRootDirectory("/home/test/.aspire/bin/aspire"); + Assert.Equal("/home/test/.aspire", result.FullName); + } + } + + [Fact] + public void GetInstallRootDirectory_DogfoodFlatLayout_ReturnsCliDirectory() + { + if (OperatingSystem.IsWindows()) + { + var result = Program.GetInstallRootDirectory(@"C:\Users\test\.aspire\dogfood\pr-1234\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire\dogfood\pr-1234", result.FullName); + } + else + { + var result = Program.GetInstallRootDirectory("/home/test/.aspire/dogfood/pr-1234/aspire"); + Assert.Equal("/home/test/.aspire/dogfood/pr-1234", result.FullName); + } + } + + [Fact] + public void GetInstallRootDirectory_NullProcessPath_FallsBackToHome() + { + var result = Program.GetInstallRootDirectory(null); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } + + [Fact] + public void GetInstallRootDirectory_NonAspireProcess_FallsBackToHome() + { + var result = Program.GetInstallRootDirectory("/usr/local/share/dotnet/dotnet"); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } } From e2775156ce577a842c1936eac48ba390076279d7 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 02:24:34 -0400 Subject: [PATCH 03/11] Fix E2E test PATH for self-contained dogfood installs The dogfood install script places the CLI binary under ~/.aspire/dogfood/pr-/ instead of ~/.aspire/bin/. Update SourceAspireBundleEnvironmentAsync and InstallAspireCliInDockerAsync to prepend the dogfood path to PATH when a PR number is known. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/CliE2EAutomatorHelpers.cs | 10 +++++++--- .../TypeScriptCodegenValidationTests.cs | 2 +- .../Helpers/DeploymentE2EAutomatorHelpers.cs | 7 +++++-- .../TypeScriptExpressDeploymentTests.cs | 2 +- .../TypeScriptVnetSqlServerInfraDeploymentTests.cs | 2 +- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 57f388cf504..56f98324c65 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -87,7 +87,8 @@ internal static async Task InstallAspireCliInDockerAsync( await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH"); + // Self-contained dogfood installs go to ~/.aspire/dogfood/pr- + await auto.TypeAsync($"export PATH=~/.aspire/dogfood/pr-{prNumber}:~/.aspire/bin:~/.aspire:$PATH"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); break; @@ -200,12 +201,15 @@ internal static async Task InstallAspireBundleFromPullRequestAsync( /// /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment. /// Unlike , this includes ~/.aspire in PATH for bundle tools. + /// When is provided, the self-contained dogfood path is also added. /// internal static async Task SourceAspireBundleEnvironmentAsync( this Hex1bTerminalAutomator auto, - SequenceCounter counter) + SequenceCounter counter, + int? prNumber = null) { - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); + var dogfoodPrefix = prNumber.HasValue ? $"~/.aspire/dogfood/pr-{prNumber}:" : ""; + await auto.TypeAsync($"export PATH={dogfoodPrefix}~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs index ef8ff4add5f..a61cf872682 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs @@ -117,7 +117,7 @@ public async Task RunWithMissingAwaitShowsHelpfulError() if (isCI) { await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs index 85fa9c741f4..fb9c49959da 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs @@ -102,12 +102,15 @@ internal static async Task InstallAspireBundleFromPullRequestAsync( /// /// Sources the Aspire Bundle environment after installation. /// Adds both the bundle's bin/ and root directories to PATH. + /// When is provided, the self-contained dogfood path is also added. /// internal static async Task SourceAspireBundleEnvironmentAsync( this Hex1bTerminalAutomator auto, - SequenceCounter counter) + SequenceCounter counter, + int? prNumber = null) { - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); + var dogfoodPrefix = prNumber.HasValue ? $"~/.aspire/dogfood/pr-{prNumber}:" : ""; + await auto.TypeAsync($"export PATH={dogfoodPrefix}~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs index 1c9cf2a26ba..5a9eeaa1462 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs @@ -83,7 +83,7 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}..."); await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); } - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber > 0 ? prNumber : null); } // Step 3: Create TypeScript Express/React project using aspire new diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs index bea38786351..72c5e0cf376 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs @@ -80,7 +80,7 @@ private async Task DeployTypeScriptVnetSqlServerInfrastructureCore(CancellationT output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}..."); await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); } - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber > 0 ? prNumber : null); } // Step 3: Create TypeScript AppHost using aspire init From a757d81b6231db8bb5c4d79c6b8d6c3236c073fa Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 02:38:17 -0400 Subject: [PATCH 04/11] Handle template version selection prompt in dogfood E2E tests Self-contained dogfood installs create a local hive that causes 'aspire new' to present a 'Select a template version' prompt. Add handling in AspireNewAsync to detect this prompt and select the PR-specific hive version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index aad2b2a8522..77d56372487 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -266,6 +266,28 @@ await auto.WaitUntilAsync( description: "output path prompt"); await auto.EnterAsync(); + // Step 4.5: Handle optional template version selection (dogfood installs add a hive + // that causes the CLI to present a version menu). Select the PR-specific version if present. + try + { + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(5), + description: "template version prompt (dogfood)"); + + // Type "pr-" to filter to the PR-specific hive version and select it + await auto.TypeAsync("pr-"); + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("> pr-").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(5), + description: "PR version selected"); + await auto.EnterAsync(); + } + catch (Hex1bAutomationException) + { + // Non-dogfood installs don't show version selection — continue normally + } + // Step 5: URLs prompt (all templates have this) await auto.WaitUntilAsync( s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, From 6ae902d126c834df950de54e1b3864bb6a809bbe Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 02:59:42 -0400 Subject: [PATCH 05/11] Fix version prompt: accept default instead of typing filter text The search filter text caused Enter to be consumed by the filter rather than selecting the highlighted item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 77d56372487..63a54dfc196 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -267,7 +267,7 @@ await auto.WaitUntilAsync( await auto.EnterAsync(); // Step 4.5: Handle optional template version selection (dogfood installs add a hive - // that causes the CLI to present a version menu). Select the PR-specific version if present. + // that causes the CLI to present a version menu). Accept the default version. try { await auto.WaitUntilAsync( @@ -275,12 +275,7 @@ await auto.WaitUntilAsync( timeout: TimeSpan.FromSeconds(5), description: "template version prompt (dogfood)"); - // Type "pr-" to filter to the PR-specific hive version and select it - await auto.TypeAsync("pr-"); - await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> pr-").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(5), - description: "PR version selected"); + // Accept the default version (the PR hive version is typically first) await auto.EnterAsync(); } catch (Hex1bAutomationException) From 639475b6c7caad1e600ddf02fee257ab4bcd42d4 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 03:07:00 -0400 Subject: [PATCH 06/11] Add AspireAddAsync helper to handle version selection in aspire add Dogfood installs present a version selection prompt in 'aspire add' commands. Add a shared helper that handles this prompt, and update all affected E2E tests to use it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaCodegenValidationTests.cs | 11 +----- .../JavaPolyglotTests.cs | 5 +-- .../JavaScriptPublishTests.cs | 9 +---- .../TypeScriptCodegenValidationTests.cs | 16 ++------ .../TypeScriptPolyglotTests.cs | 7 +--- .../TypeScriptPublishTests.cs | 11 +----- .../TypeScriptReusablePackageTests.cs | 5 +-- tests/Shared/Hex1bAutomatorTestHelpers.cs | 38 +++++++++++++++++++ 8 files changed, 50 insertions(+), 52 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs index 033ec31a64d..6ea569d6fea 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs @@ -42,15 +42,8 @@ public async Task RestoreGeneratesSdkFiles() await auto.WaitUntilTextAsync("Created AppHost.java", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); + await auto.AspireAddAsync("Aspire.Hosting.SqlServer", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs index 6742deae355..41463ff0c81 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs @@ -50,10 +50,7 @@ public async Task CreateJavaAppHostWithViteApp() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter); var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.java"); var newContent = """ diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs index 194e81090da..5164c3baa2c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs @@ -53,13 +53,8 @@ public async Task AllPublishMethodsBuildDockerImages() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); - - await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter, timeout: TimeSpan.FromSeconds(180)); + await auto.AspireAddAsync("Aspire.Hosting.Docker", counter, timeout: TimeSpan.FromSeconds(180)); // Copy checked-in fixture apps and write the apphost CopyFixtures(workspace); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs index a61cf872682..38a281c4530 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs @@ -40,15 +40,8 @@ public async Task RestoreGeneratesSdkFiles() await auto.WaitForSuccessPromptAsync(counter); // Step 2: Add two integrations - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); + await auto.AspireAddAsync("Aspire.Hosting.SqlServer", counter); // Step 3: Run aspire restore and verify success await auto.TypeAsync("aspire restore"); @@ -126,10 +119,7 @@ public async Task RunWithMissingAwaitShowsHelpfulError() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.PostgreSQL", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 9551e830709..8b3f1cf1f32 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -58,12 +58,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 4: Add Aspire.Hosting.JavaScript package - // When channel is set (CI) and there's only one channel with one version, - // the version is auto-selected without prompting. - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter); // Step 5: Modify apphost.ts to add the Vite app var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs index 995512c31d5..a11e2e4aa81 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs @@ -43,15 +43,8 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Docker", counter); + await auto.AspireAddAsync("Aspire.Hosting.PostgreSQL", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs index 779d73bc7cc..eb8a26b2971 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs @@ -47,10 +47,7 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() var appAppHostPath = Path.Combine(appDirectory.FullName, "apphost.ts"); Assert.True(File.Exists(appAppHostPath), $"Expected the CLI-created app to contain {appAppHostPath}."); - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); var sdkVersion = GetSdkVersion(appDirectory); WriteHelperPackageFiles(helperDirectory, helperSourceDirectory, sdkVersion); diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 63a54dfc196..2773e30c918 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -352,4 +352,42 @@ await auto.WaitUntilAsync( await auto.DeclineAgentInitPromptAsync(counter); } + + /// + /// Runs aspire add {packageName} and handles the optional version selection prompt + /// that appears with dogfood installs. Waits for the success prompt. + /// + internal static async Task AspireAddAsync( + this Hex1bTerminalAutomator auto, + string packageName, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(2); + + await auto.TypeAsync($"aspire add {packageName}"); + await auto.EnterAsync(); + + // Dogfood installs may present a version selection prompt for the package. + // Wait for either the version selection prompt or the package success message. + var versionSelectionShown = false; + await auto.WaitUntilAsync(s => + { + if (new CellPatternSearcher().Find("Select a version of").Search(s).Count > 0) + { + versionSelectionShown = true; + return true; + } + + return new CellPatternSearcher().Find("The package ").Search(s).Count > 0; + }, timeout: effectiveTimeout, description: "version selection or package added"); + + if (versionSelectionShown) + { + // Accept the default version + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter); + } } From 328e20071333643c113b4b5e4e985675ac3bcd62 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 03:29:34 -0400 Subject: [PATCH 07/11] Handle version prompt at both positions in aspire new flow The version selection prompt appears at different points depending on the template: after template selection (PythonReact) or after output path (Starter). Add try/catch handlers at both positions so either location is handled correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 2773e30c918..bc7f3eaa1e5 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -252,6 +252,24 @@ await auto.WaitUntilAsync( default: throw new ArgumentOutOfRangeException(nameof(template), template, $"Unsupported template: {template}"); } + + // Handle optional template version selection (dogfood installs add a hive + // that causes the CLI to present a version menu). Accept the default version. + try + { + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(5), + description: "template version prompt (dogfood)"); + + // Accept the default version (the PR hive version is typically first) + await auto.EnterAsync(); + } + catch (Hex1bAutomationException) + { + // Non-dogfood installs don't show version selection — continue normally + } + await auto.WaitUntilAsync( s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(10), @@ -266,21 +284,20 @@ await auto.WaitUntilAsync( description: "output path prompt"); await auto.EnterAsync(); - // Step 4.5: Handle optional template version selection (dogfood installs add a hive - // that causes the CLI to present a version menu). Accept the default version. + // Handle optional template version selection for templates that show + // the version prompt after the output path (e.g. Starter). Accept default. try { await auto.WaitUntilAsync( s => new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(5), - description: "template version prompt (dogfood)"); + description: "template version prompt after output path (dogfood)"); - // Accept the default version (the PR hive version is typically first) await auto.EnterAsync(); } catch (Hex1bAutomationException) { - // Non-dogfood installs don't show version selection — continue normally + // No version prompt at this point — continue normally } // Step 5: URLs prompt (all templates have this) From 74297544cc45d84eb11e4514b9f1b4f69726b68f Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 10:06:39 -0400 Subject: [PATCH 08/11] Fix AspireAddAsync stale text matching in version selection Use package-specific patterns in WaitUntilAsync to avoid matching leftover 'Select a version of' text from a prior aspire add command still visible on the terminal screen. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index bc7f3eaa1e5..64238fdd0ff 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -387,17 +387,20 @@ internal static async Task AspireAddAsync( // Dogfood installs may present a version selection prompt for the package. // Wait for either the version selection prompt or the package success message. + // Use the specific package name to avoid matching stale output from a prior add. var versionSelectionShown = false; + var shortPackageName = packageName.Split('.')[^1]; // e.g. "SqlServer" from "Aspire.Hosting.SqlServer" await auto.WaitUntilAsync(s => { - if (new CellPatternSearcher().Find("Select a version of").Search(s).Count > 0) + if (new CellPatternSearcher().Find($"Select a version of {packageName}").Search(s).Count > 0) { versionSelectionShown = true; return true; } - return new CellPatternSearcher().Find("The package ").Search(s).Count > 0; - }, timeout: effectiveTimeout, description: "version selection or package added"); + // Check for success text with the short package name to avoid stale matches + return new CellPatternSearcher().Find($"The package {shortPackageName}").Search(s).Count > 0; + }, timeout: effectiveTimeout, description: $"version selection or package added ({shortPackageName})"); if (versionSelectionShown) { From 198562789b3e7be307e3188bfe4729102f610e44 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 13:24:46 -0400 Subject: [PATCH 09/11] Fix AspireAddAsync to use full package name in pattern matching Use full package name (e.g. 'Aspire.Hosting.JavaScript') instead of short name ('JavaScript') when matching success text, since the CLI outputs 'The package Aspire.Hosting.JavaScript::version was added'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 64238fdd0ff..c473a7284e0 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -387,9 +387,7 @@ internal static async Task AspireAddAsync( // Dogfood installs may present a version selection prompt for the package. // Wait for either the version selection prompt or the package success message. - // Use the specific package name to avoid matching stale output from a prior add. var versionSelectionShown = false; - var shortPackageName = packageName.Split('.')[^1]; // e.g. "SqlServer" from "Aspire.Hosting.SqlServer" await auto.WaitUntilAsync(s => { if (new CellPatternSearcher().Find($"Select a version of {packageName}").Search(s).Count > 0) @@ -398,9 +396,8 @@ await auto.WaitUntilAsync(s => return true; } - // Check for success text with the short package name to avoid stale matches - return new CellPatternSearcher().Find($"The package {shortPackageName}").Search(s).Count > 0; - }, timeout: effectiveTimeout, description: $"version selection or package added ({shortPackageName})"); + return new CellPatternSearcher().Find($"The package {packageName}").Search(s).Count > 0; + }, timeout: effectiveTimeout, description: $"version selection or package added ({packageName})"); if (versionSelectionShown) { From 737ac6ed749080d806a55e2f4575bca5d37664e7 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 14:02:24 -0400 Subject: [PATCH 10/11] Fix version prompt race condition with combined wait approach Replace try/catch blocks with 5-second timeouts that race against the version prompt with a combined WaitUntilAsync that checks for EITHER the version prompt OR the next expected prompt. This eliminates the race condition where the CLI takes longer than 5 seconds to show the version prompt after pressing Enter on the output path. Also tracks whether the version prompt was already handled at position 1 (after template selection) to skip the check at position 2 (after output path), preventing stale text from triggering a false match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Shared/Hex1bAutomatorTestHelpers.cs | 88 ++++++++++++++--------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index c473a7284e0..ab4467e908e 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -253,27 +253,34 @@ await auto.WaitUntilAsync( throw new ArgumentOutOfRangeException(nameof(template), template, $"Unsupported template: {template}"); } - // Handle optional template version selection (dogfood installs add a hive - // that causes the CLI to present a version menu). Accept the default version. - try - { - await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(5), - description: "template version prompt (dogfood)"); + // Dogfood installs may show "Select a template version" before the project + // name prompt. Wait for whichever appears first to avoid a race condition + // where a fixed timeout misses a slow-appearing version prompt. + var versionPromptHandled = false; + var sawVersionPrompt1 = false; + await auto.WaitUntilAsync( + s => + { + if (new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0) + { + sawVersionPrompt1 = true; + return true; + } + return new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0; + }, + timeout: TimeSpan.FromSeconds(15), + description: "version prompt or project name prompt"); - // Accept the default version (the PR hive version is typically first) - await auto.EnterAsync(); - } - catch (Hex1bAutomationException) + if (sawVersionPrompt1) { - // Non-dogfood installs don't show version selection — continue normally - } + versionPromptHandled = true; + await auto.EnterAsync(); // Accept the default version - await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(10), - description: "project name prompt"); + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "project name prompt"); + } await auto.TypeAsync(projectName); await auto.EnterAsync(); @@ -284,27 +291,44 @@ await auto.WaitUntilAsync( description: "output path prompt"); await auto.EnterAsync(); - // Handle optional template version selection for templates that show - // the version prompt after the output path (e.g. Starter). Accept default. - try + // Dogfood installs may show "Select a template version" after the output + // path (e.g. Starter template). Only check if not already handled above + // to avoid matching stale text from a previous version selection. + if (!versionPromptHandled) { + var sawVersionPrompt2 = false; await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(5), - description: "template version prompt after output path (dogfood)"); + s => + { + if (new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0) + { + sawVersionPrompt2 = true; + return true; + } + return new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0; + }, + timeout: TimeSpan.FromSeconds(15), + description: "version prompt or URLs prompt"); + + if (sawVersionPrompt2) + { + await auto.EnterAsync(); // Accept the default version - await auto.EnterAsync(); + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "URLs prompt"); + } } - catch (Hex1bAutomationException) + else { - // No version prompt at this point — continue normally + // Version already handled at position 1 — just wait for the URLs prompt + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(15), + description: "URLs prompt"); } - // Step 5: URLs prompt (all templates have this) - await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(10), - description: "URLs prompt"); await auto.EnterAsync(); // Accept default "No" // Step 6: Redis prompt (only Starter, JsReact, PythonReact) From 774d9f9fad868d1f5bdcc959a693a5038f971761 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 18:08:56 -0400 Subject: [PATCH 11/11] Retrigger CI: MCR 403 resolved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>