diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8ee92baad8a..2f1eca40444 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -137,14 +137,15 @@
-
-
+
+
-
+
+
diff --git a/eng/Signing.props b/eng/Signing.props
index 4de73bac6a3..54e9749407c 100644
--- a/eng/Signing.props
+++ b/eng/Signing.props
@@ -57,8 +57,11 @@
+
+
+
@@ -73,6 +76,11 @@
+
+
+
+
+
13
2
- 2
+ 4
$(MajorVersion).$(MinorVersion).$(PatchVersion)
preview.1
net8.0
@@ -50,11 +50,11 @@
10.2.0
10.2.0
- 1.15.0
- 1.15.0
- 1.15.0
- 1.15.0
- 1.15.0
+ 1.15.2
+ 1.15.1
+ 1.15.3
+ 1.15.1
+ 1.15.3
1.5.0
2.23.32-alpha
diff --git a/eng/aspire-managed-entitlements.plist b/eng/aspire-managed-entitlements.plist
new file mode 100644
index 00000000000..fde7a18e473
--- /dev/null
+++ b/eng/aspire-managed-entitlements.plist
@@ -0,0 +1,18 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml
index 5f3bf630398..2e3609079fa 100644
--- a/eng/pipelines/templates/BuildAndTest.yml
+++ b/eng/pipelines/templates/BuildAndTest.yml
@@ -41,19 +41,42 @@ parameters:
steps:
# Internal pipeline: Build with pack+sign
- ${{ if ne(parameters.runAsPublic, 'true') }}:
- # Build bundle payload (aspire-managed) for each target RID before the main build
+ # Build, publish, and sign aspire-managed for each target RID before the main build.
+ # This ensures aspire-managed is signed before CreateLayout packs it into the bundle archive.
+ # Step 1: dotnet publish produces the self-contained single-file binary.
+ # Step 2: build.cmd -sign runs Arcade's signing, which picks up the binary via ItemsToSign in Signing.props.
+ # Step 3: Bundle.proj creates the bundle layout and archive using the signed binary.
- ${{ each targetRid in parameters.targetRids }}:
+ - script: ${{ parameters.dotnetScript }}
+ publish
+ $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj
+ -c ${{ parameters.buildConfig }}
+ -r ${{ targetRid }}
+ --self-contained
+ /p:GenerateDocumentationFile=false
+ /p:EnforceCodeStyleInBuild=false
+ $(_OfficialBuildIdArgs)
+ /bl:${{ parameters.repoLogPath }}/PublishManaged-${{ targetRid }}.binlog
+ displayName: 🟣Publish aspire-managed (${{ targetRid }})
+
+ - script: ${{ parameters.buildScript }}
+ -restore -sign $(_SignArgs)
+ -configuration ${{ parameters.buildConfig }}
+ $(_OfficialBuildIdArgs)
+ -projects $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj
+ /bl:${{ parameters.repoLogPath }}/SignManaged-${{ targetRid }}.binlog
+ displayName: 🟣Sign aspire-managed (${{ targetRid }})
+
- script: ${{ parameters.dotnetScript }}
msbuild
$(Build.SourcesDirectory)/eng/Bundle.proj
- /restore
+ "/t:_RestoreDcpPackage;_RunCreateLayout"
/p:Configuration=${{ parameters.buildConfig }}
/p:TargetRid=${{ targetRid }}
/p:BundleVersion=ci-bundlepayload
- /p:SkipNativeBuild=true
/p:ContinuousIntegrationBuild=true
- /bl:${{ parameters.repoLogPath }}/BundlePayload-${{ targetRid }}.binlog
- displayName: 🟣Build bundle payload (${{ targetRid }})
+ /bl:${{ parameters.repoLogPath }}/BundleLayout-${{ targetRid }}.binlog
+ displayName: 🟣Create bundle layout (${{ targetRid }})
- script: ${{ parameters.buildScript }}
-restore -build
@@ -69,6 +92,11 @@ steps:
/p:BuildExtension=true
displayName: 🟣Build
+ # NOTE: The 🟣Verify CLI archive (win-x64) step has been temporarily disabled on release/13.2.
+ # It is hanging/timing out on Windows agents similarly to the osx-arm64 verification step that
+ # was previously disabled. Tracking issue: re-enable once the hang on hosted agents is resolved.
+
+
# Log MicroBuild environment for debugging
# MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml
# which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps.
diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml
index 7ee70558dae..8523f512248 100644
--- a/eng/pipelines/templates/build_sign_native.yml
+++ b/eng/pipelines/templates/build_sign_native.yml
@@ -22,7 +22,7 @@ jobs:
jobs:
- job: BuildNative_${{ replace(targetRid, '-', '_') }}
displayName: ${{ replace(targetRid, '-', '_') }}
- timeoutInMinutes: 40
+ timeoutInMinutes: 60
variables:
- TeamName: ${{ parameters.teamName }}
@@ -63,18 +63,67 @@ jobs:
displayName: 🟣Restore
steps:
+ # Build, publish, and sign aspire-managed before creating the bundle layout.
+ # This ensures aspire-managed is signed before CreateLayout packs it into the bundle archive.
+ # Step 1: dotnet publish produces the self-contained single-file binary.
+ # Step 2: build.sh --sign runs Arcade's signing (only when codeSign is enabled).
+ # Step 3: Bundle.proj creates the bundle layout and archive using the signed binary.
+ - script: >-
+ $(Build.SourcesDirectory)/$(dotnetScript)
+ publish
+ $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj
+ -c $(_BuildConfig)
+ -r ${{ targetRid }}
+ --self-contained
+ /p:GenerateDocumentationFile=false
+ /p:EnforceCodeStyleInBuild=false
+ ${{ parameters.extraBuildArgs }}
+ /bl:$(Build.Arcade.LogsPath)PublishManaged.binlog
+ displayName: 🟣Publish aspire-managed
+
+ # On macOS, ad-hoc codesign aspire-managed with JIT entitlements BEFORE Arcade signing.
+ # MicroBuild (MacDeveloperHardenWithNotarization) preserves entitlements from the prior
+ # ad-hoc signature when re-signing with the real certificate. Without this step,
+ # hardened runtime blocks CoreCLR JIT (W^X memory mapping) causing HRESULT: 0x80070008.
+ # This follows the same pattern used by dotnet/sdk for managed binaries (roslyn-entitlements.plist).
+ - ${{ if eq(parameters.agentOs, 'macos') }}:
+ - script: >-
+ codesign --sign - --force
+ --entitlements $(Build.SourcesDirectory)/eng/aspire-managed-entitlements.plist
+ $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed
+ displayName: 🟣Ad-hoc codesign aspire-managed with JIT entitlements
+
+ - ${{ if eq(parameters.codeSign, true) }}:
+ - script: >-
+ $(Build.SourcesDirectory)/$(scriptName)
+ --restore --sign
+ ${{ parameters.extraBuildArgs }}
+ -projects $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj
+ /bl:$(Build.Arcade.LogsPath)SignManaged.binlog
+ displayName: 🟣Sign aspire-managed
+ env:
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken)
+
+ # On macOS/Linux, restore the execute bit after Arcade signing.
+ # MicroBuild rewrites the binary file during signing, which resets permissions
+ # to the default umask (typically 644). The execute bit must be restored before
+ # CreateLayout packs the binary into the bundle archive.
+ - ${{ if and(eq(parameters.codeSign, true), ne(parameters.agentOs, 'windows')) }}:
+ - script: >-
+ chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed
+ displayName: 🟣Restore execute permission on aspire-managed
+
- script: >-
$(Build.SourcesDirectory)/$(dotnetScript)
msbuild
$(Build.SourcesDirectory)/eng/Bundle.proj
- /restore
+ "/t:_RestoreDcpPackage;_RunCreateLayout"
/p:Configuration=$(_BuildConfig)
/p:TargetRid=${{ targetRid }}
/p:BundleVersion=ci-bundlepayload
- /p:SkipNativeBuild=true
/p:ContinuousIntegrationBuild=true
- /bl:$(Build.Arcade.LogsPath)BundlePayload.binlog
- displayName: 🟣Build bundle payload
+ /bl:$(Build.Arcade.LogsPath)BundleLayout.binlog
+ displayName: 🟣Create bundle layout
- script: >-
$(Build.SourcesDirectory)/$(scriptName)
@@ -91,6 +140,11 @@ jobs:
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Needed for the signing task
+ # NOTE: The 🟣Verify CLI archive step has been temporarily disabled on release/13.2.
+ # On osx-arm64 it hangs at "🔐 Trusting certificates..." during `aspire new`,
+ # consuming the entire 60 minute job timeout. Tracking issue: re-enable once the
+ # dev-cert trust hang on macOS hosted agents is resolved.
+
- task: 1ES.PublishBuildArtifacts@1
displayName: 🟣Publish Artifacts
condition: always()
diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1
new file mode 100644
index 00000000000..69699c45f10
--- /dev/null
+++ b/eng/scripts/verify-cli-archive.ps1
@@ -0,0 +1,171 @@
+<#
+.SYNOPSIS
+ Verify that a signed Aspire CLI archive produces a working binary.
+
+.DESCRIPTION
+ This script:
+ 1. Cleans ~/.aspire to ensure no stale state
+ 2. Extracts the CLI archive to a temp location
+ 3. Runs 'aspire --version' to validate the binary executes
+ 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation
+ 5. Cleans up temp directories
+
+.PARAMETER ArchivePath
+ Path to the CLI archive (.zip or .tar.gz)
+
+.EXAMPLE
+ .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip"
+#>
+
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [string]$ArchivePath
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan }
+function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green }
+function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red }
+
+$verifyTmpDir = $null
+$aspireBackup = $null
+
+function Invoke-Cleanup {
+ if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) {
+ Write-Step "Cleaning up temp directory: $verifyTmpDir"
+ Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue
+ }
+ # Restore ~/.aspire if we backed it up
+ $aspireDir = Join-Path $env:USERPROFILE ".aspire"
+ if ($aspireBackup -and (Test-Path $aspireBackup)) {
+ if (Test-Path $aspireDir) {
+ Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue
+ }
+ Move-Item $aspireBackup $aspireDir
+ Write-Step "Restored original ~/.aspire"
+ }
+}
+
+try {
+ # Validate archive exists
+ if (-not (Test-Path $ArchivePath)) {
+ Write-Err "Archive not found: $ArchivePath"
+ exit 1
+ }
+
+ $ArchivePath = (Resolve-Path $ArchivePath).Path
+
+ # Suppress interactive prompts and telemetry
+ $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true"
+ $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true"
+ $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true"
+ $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false"
+
+ Write-Host ""
+ Write-Host "=========================================="
+ Write-Host " Aspire CLI Archive Verification"
+ Write-Host "=========================================="
+ Write-Host " Archive: $ArchivePath"
+ Write-Host "=========================================="
+ Write-Host ""
+
+ # Step 1: Back up and clean ~/.aspire
+ Write-Step "Cleaning ~/.aspire state..."
+ $aspireDir = Join-Path $env:USERPROFILE ".aspire"
+ if (Test-Path $aspireDir) {
+ $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())"
+ Move-Item $aspireDir $aspireBackup
+ Write-Step "Backed up existing ~/.aspire to $aspireBackup"
+ }
+ Write-Ok "Clean ~/.aspire state"
+
+ # Step 2: Extract the archive
+ $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())"
+ $extractDir = Join-Path $verifyTmpDir "cli"
+ New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
+
+ Write-Step "Extracting archive to $extractDir..."
+ if ($ArchivePath.EndsWith(".zip")) {
+ Expand-Archive -Path $ArchivePath -DestinationPath $extractDir
+ }
+ elseif ($ArchivePath.EndsWith(".tar.gz")) {
+ tar -xzf $ArchivePath -C $extractDir
+ if ($LASTEXITCODE -ne 0) {
+ Write-Err "Failed to extract tar.gz archive"
+ exit 1
+ }
+ }
+ else {
+ Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)"
+ exit 1
+ }
+
+ # Find the aspire binary
+ $aspireBin = Join-Path $extractDir "aspire.exe"
+ if (-not (Test-Path $aspireBin)) {
+ $aspireBin = Join-Path $extractDir "aspire"
+ if (-not (Test-Path $aspireBin)) {
+ Write-Err "Could not find 'aspire' binary in extracted archive."
+ Get-ChildItem $extractDir | Format-Table
+ exit 1
+ }
+ }
+ Write-Ok "Extracted CLI binary: $aspireBin"
+
+ # Install to ~/.aspire/bin so self-extraction works correctly
+ Write-Step "Installing CLI to ~/.aspire/bin..."
+ $aspireDir = Join-Path $env:USERPROFILE ".aspire"
+ $aspireBinDir = Join-Path $aspireDir "bin"
+ New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null
+ Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf))
+ $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)
+ $env:PATH = "$aspireBinDir;$env:PATH"
+ Write-Ok "CLI installed to ~/.aspire/bin"
+
+ # Step 3: Verify aspire --version
+ Write-Step "Running 'aspire --version'..."
+ $versionOutput = & $aspireBin --version 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Err "'aspire --version' failed with exit code $LASTEXITCODE"
+ Write-Host "Output: $versionOutput"
+ exit 1
+ }
+ Write-Host " Version: $versionOutput"
+ Write-Ok "'aspire --version' succeeded"
+
+ # Step 4: Create a new project with aspire new
+ # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding)
+ $projectDir = Join-Path $verifyTmpDir "VerifyApp"
+ New-Item -ItemType Directory -Path $projectDir -Force | Out-Null
+
+ Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..."
+ & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host
+ if ($LASTEXITCODE -ne 0) {
+ Write-Err "'aspire new' failed with exit code $LASTEXITCODE"
+ exit 1
+ }
+
+ # Verify the project was created
+ $appHostDir = Join-Path $projectDir "VerifyApp.AppHost"
+ if (-not (Test-Path $appHostDir)) {
+ Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'"
+ Get-ChildItem $projectDir | Format-Table
+ exit 1
+ }
+ Write-Ok "'aspire new' created project successfully"
+
+ Write-Host ""
+ Write-Host "=========================================="
+ Write-Host " All verification checks passed!" -ForegroundColor Green
+ Write-Host "=========================================="
+ Write-Host ""
+}
+catch {
+ Write-Err "Verification failed: $_"
+ Write-Host $_.ScriptStackTrace
+ exit 1
+}
+finally {
+ Invoke-Cleanup
+}
diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh
new file mode 100755
index 00000000000..40676d1ba10
--- /dev/null
+++ b/eng/scripts/verify-cli-archive.sh
@@ -0,0 +1,205 @@
+#!/usr/bin/env bash
+
+# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary.
+#
+# Usage: ./verify-cli-archive.sh
+#
+# This script:
+# 1. Cleans ~/.aspire to ensure no stale state
+# 2. Extracts the CLI archive to a temp location
+# 3. Runs 'aspire --version' to validate the binary executes
+# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation
+# 5. Cleans up temp directories
+
+set -euo pipefail
+
+readonly RED='\033[0;31m'
+readonly GREEN='\033[0;32m'
+readonly CYAN='\033[0;36m'
+readonly RESET='\033[0m'
+
+ARCHIVE_PATH=""
+
+log_step() { echo -e "${CYAN}▶ $1${RESET}"; }
+log_ok() { echo -e "${GREEN}✅ $1${RESET}"; }
+log_err() { echo -e "${RED}❌ $1${RESET}"; }
+
+show_help() {
+ cat << 'EOF'
+Aspire CLI Archive Verification Script
+
+USAGE:
+ verify-cli-archive.sh
+
+ARGUMENTS:
+ Path to the CLI archive (.tar.gz or .zip)
+
+OPTIONS:
+ -h, --help Show this help message
+
+DESCRIPTION:
+ Verifies that a signed Aspire CLI archive produces a working binary by:
+ 1. Extracting the archive
+ 2. Running 'aspire --version'
+ 3. Creating a new project with 'aspire new'
+EOF
+ exit 0
+}
+
+cleanup() {
+ local exit_code=$?
+ if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then
+ log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}"
+ rm -rf "${VERIFY_TMPDIR}"
+ fi
+ # Restore ~/.aspire if we backed it up
+ if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then
+ if [[ -d "$HOME/.aspire" ]]; then
+ rm -rf "$HOME/.aspire"
+ fi
+ mv "${ASPIRE_BACKUP}" "$HOME/.aspire"
+ log_step "Restored original ~/.aspire"
+ fi
+ if [[ $exit_code -ne 0 ]]; then
+ log_err "Verification FAILED (exit code: $exit_code)"
+ fi
+ exit $exit_code
+}
+
+trap cleanup EXIT
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help)
+ show_help
+ ;;
+ -*)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ *)
+ if [[ -z "$ARCHIVE_PATH" ]]; then
+ ARCHIVE_PATH="$1"
+ else
+ echo "Unexpected argument: $1" >&2
+ exit 1
+ fi
+ shift
+ ;;
+ esac
+done
+
+if [[ -z "$ARCHIVE_PATH" ]]; then
+ echo "Error: archive path is required." >&2
+ echo "Usage: verify-cli-archive.sh " >&2
+ exit 1
+fi
+
+if [[ ! -f "$ARCHIVE_PATH" ]]; then
+ log_err "Archive not found: $ARCHIVE_PATH"
+ exit 1
+fi
+
+echo ""
+echo "=========================================="
+echo " Aspire CLI Archive Verification"
+echo "=========================================="
+echo " Archive: $ARCHIVE_PATH"
+echo "=========================================="
+echo ""
+
+# Suppress interactive prompts and telemetry
+export ASPIRE_CLI_TELEMETRY_OPTOUT=true
+export DOTNET_CLI_TELEMETRY_OPTOUT=true
+export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true
+export DOTNET_GENERATE_ASPNET_CERTIFICATE=false
+
+VERIFY_TMPDIR="$(mktemp -d)"
+
+# Step 1: Back up and clean ~/.aspire
+log_step "Cleaning ~/.aspire state..."
+ASPIRE_BACKUP=""
+if [[ -d "$HOME/.aspire" ]]; then
+ ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire"
+ mkdir -p "${VERIFY_TMPDIR}/aspire-backup"
+ mv "$HOME/.aspire" "${ASPIRE_BACKUP}"
+ log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}"
+fi
+log_ok "Clean ~/.aspire state"
+
+# Step 2: Extract the archive
+EXTRACT_DIR="${VERIFY_TMPDIR}/cli"
+mkdir -p "$EXTRACT_DIR"
+
+log_step "Extracting archive to ${EXTRACT_DIR}..."
+if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then
+ tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR"
+elif [[ "$ARCHIVE_PATH" == *.zip ]]; then
+ unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR"
+else
+ log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)"
+ exit 1
+fi
+
+# Find the aspire binary
+ASPIRE_BIN=""
+if [[ -f "$EXTRACT_DIR/aspire" ]]; then
+ ASPIRE_BIN="$EXTRACT_DIR/aspire"
+elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then
+ ASPIRE_BIN="$EXTRACT_DIR/aspire.exe"
+else
+ log_err "Could not find 'aspire' binary in extracted archive. Contents:"
+ ls -la "$EXTRACT_DIR"
+ exit 1
+fi
+
+chmod +x "$ASPIRE_BIN"
+log_ok "Extracted CLI binary: $ASPIRE_BIN"
+
+# Install the CLI to ~/.aspire/bin so self-extraction works correctly
+log_step "Installing CLI to ~/.aspire/bin..."
+mkdir -p "$HOME/.aspire/bin"
+cp "$ASPIRE_BIN" "$HOME/.aspire/bin/"
+ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")"
+chmod +x "$ASPIRE_BIN"
+export PATH="$HOME/.aspire/bin:$PATH"
+log_ok "CLI installed to ~/.aspire/bin"
+
+# Step 3: Verify aspire --version
+log_step "Running 'aspire --version'..."
+VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || {
+ log_err "'aspire --version' failed with exit code $?"
+ echo "Output: $VERSION_OUTPUT"
+ exit 1
+}
+echo " Version: $VERSION_OUTPUT"
+log_ok "'aspire --version' succeeded"
+
+# Step 4: Create a new project with aspire new
+# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding)
+PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp"
+mkdir -p "$PROJECT_DIR"
+
+log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..."
+"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || {
+ log_err "'aspire new' failed"
+ echo "Contents of project directory:"
+ find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true
+ exit 1
+}
+
+# Verify the project was actually created
+if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then
+ log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'"
+ echo "Contents of project directory:"
+ ls -la "$PROJECT_DIR"
+ exit 1
+fi
+log_ok "'aspire new' created project successfully"
+
+echo ""
+echo "=========================================="
+echo -e " ${GREEN}All verification checks passed!${RESET}"
+echo "=========================================="
+echo ""
diff --git a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json
index 7a511945b21..cbdb6039472 100644
--- a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json
+++ b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json
@@ -10,7 +10,6 @@
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17299",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100",
- //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:17299",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17299",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ENABLE_CONTAINER_TUNNEL": "true"
@@ -26,7 +25,6 @@
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17300",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101",
- //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:17300",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17300",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
diff --git a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs
index 32279e74161..ab263941504 100644
--- a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs
+++ b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs
@@ -155,7 +155,8 @@ private async Task InstallCoreAsync(AgentEnvironmentScanContext context, C
ExpectedWorkflowPath,
ExpectedBuildType,
refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) &&
- string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal),
+ (string.Equals(refInfo.Name, $"{packageInfo.Version}", StringComparison.Ordinal) ||
+ string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal)),
cancellationToken,
sriIntegrity: packageInfo.Integrity);
diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index 2efc1e9bf9f..e3c863981d9 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -125,6 +125,7 @@
+
diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs
index 44dc69d4c22..1ac612654a3 100644
--- a/src/Aspire.Cli/Commands/ExportCommand.cs
+++ b/src/Aspire.Cli/Commands/ExportCommand.cs
@@ -259,7 +259,7 @@ private static async Task AddStructuredLogsAsync(
IReadOnlyList allOtlpResources,
CancellationToken cancellationToken)
{
- var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources);
+ var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit);
var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
@@ -290,7 +290,7 @@ private static async Task AddTracesAsync(
IReadOnlyList allOtlpResources,
CancellationToken cancellationToken)
{
- var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources);
+ var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit);
var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs
index 9969d0c22a2..271208d3f17 100644
--- a/src/Aspire.Cli/Commands/StopCommand.cs
+++ b/src/Aspire.Cli/Commands/StopCommand.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
-using System.Diagnostics;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
@@ -193,16 +192,12 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection
_interactionService.DisplayMessage(KnownEmojis.StopSign, "Sending stop signal...");
- // Get the CLI process ID - this is the process we need to kill
- // Killing the CLI process will tear down everything including the AppHost
- var cliProcessId = appHostInfo?.CliProcessId;
-
- if (cliProcessId is int cliPid)
+ if (appHostInfo?.CliProcessId is int cliPid)
{
_logger.LogDebug("Sending stop signal to CLI process (PID {Pid})", cliPid);
try
{
- SendStopSignal(cliPid);
+ SendStopSignal(cliPid, appHostInfo?.CliStartedAt);
}
catch (Exception ex)
{
@@ -213,7 +208,7 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection
}
else
{
- // Fallback: Try the RPC method if we don't have CLI process ID
+ // Fallback: Try the RPC method if we don't have CLI process ID.
_logger.LogDebug("No CLI process ID available, trying RPC stop");
var rpcSucceeded = false;
try
@@ -228,10 +223,10 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection
// If RPC didn't work, try sending SIGINT to AppHost process directly
if (!rpcSucceeded && appHostInfo?.ProcessId is int appHostPid)
{
- _logger.LogDebug("RPC stop not available, sending SIGINT to AppHost PID {Pid}", appHostPid);
+ _logger.LogDebug("RPC stop not available, sending SIGTERM to AppHost PID {Pid}", appHostPid);
try
{
- SendStopSignal(appHostPid);
+ SendStopSignal(appHostPid, appHostInfo?.StartedAt);
}
catch (Exception ex)
{
@@ -247,21 +242,37 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection
}
}
+ var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
var stopped = await _interactionService.ShowStatusAsync(
StopCommandStrings.StoppingAppHost,
async () =>
{
try
{
- // Wait for processes to terminate
- var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
+ if (appHostInfo is null)
+ {
+ return true;
+ }
+
+ if (await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false))
+ {
+ return true;
+ }
- if (appHostInfo is not null)
+ var procsToKill = new HashSet<(int, DateTimeOffset?)> { (appHostInfo.ProcessId, appHostInfo.StartedAt) };
+
+ if (appHostInfo.CliProcessId is int cliPid)
+ {
+ procsToKill.Add((cliPid, appHostInfo.CliStartedAt));
+ }
+
+ foreach (var (pid, startTime) in procsToKill)
{
- return await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false);
+ _logger.LogWarning("AppHost did not stop gracefully within timeout. Forcing process {Pid} to terminate.", pid);
+ ForceKillProcess(pid, startTime);
}
- return true;
+ return await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -286,28 +297,21 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection
}
///
- /// Sends a stop signal to a process to terminate it and its process tree.
- /// Uses Process.Kill(entireProcessTree: true) to ensure all child processes are terminated.
+ /// Sends a best-effort graceful shutdown signal to the target process.
+ /// Uses Ctrl-Break on Windows and SIGTERM on non-Windows.
///
- private static void SendStopSignal(int pid)
+ private void SendStopSignal(int pid, DateTimeOffset? startTime)
{
- try
- {
- using var process = Process.GetProcessById(pid);
- process.Kill(entireProcessTree: true);
- }
- catch (ArgumentException)
- {
- // Process doesn't exist - already terminated
- }
- catch (InvalidOperationException)
- {
- // Process has already exited
- }
- catch (Exception)
- {
- // Some other error (e.g., permission denied) - ignore
- }
+ ProcessSignaler.RequestGracefulShutdown(pid, startTime, _logger);
+ }
+
+ ///
+ /// Forcefully kills the target process after the graceful shutdown timeout elapses.
+ /// This does not terminate the entire process tree.
+ ///
+ private void ForceKillProcess(int pid, DateTimeOffset? startTime)
+ {
+ ProcessSignaler.ForceKill(pid, startTime, _logger);
}
}
diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
index b327cd2476a..a6e41c0b12c 100644
--- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
+++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
@@ -26,6 +26,12 @@ internal static class TelemetryCommandHelpers
///
internal const string ApiKeyHeaderName = "X-API-Key";
+ ///
+ /// Limit passed to dashboard telemetry APIs. All data is fetched in one API call
+ /// so there shouldn't be a limit on data returned.
+ ///
+ internal const int MaxTelemetryLimit = int.MaxValue;
+
#region Shared Command Options
///
diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
index d07af446a8e..70d768ae7da 100644
--- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
@@ -129,22 +129,10 @@ private async Task FetchLogsAsync(
return ExitCodeConstants.InvalidCommand;
}
- // Build query string with multiple resource parameters
- var additionalParams = new List<(string key, string? value)>
- {
- ("traceId", traceId),
- ("severity", severity)
- };
- if (limit.HasValue && !follow)
- {
- additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
- }
- if (follow)
- {
- additionalParams.Add(("follow", "true"));
- }
+ // Build URL with query parameters
+ int? effectiveLimit = (limit.HasValue && !follow) ? limit.Value : null;
- var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
+ var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, traceId: traceId, severity: severity, limit: effectiveLimit, follow: follow ? true : null);
try
{
diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
index 7c06a63ede3..84ff7bdb122 100644
--- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
@@ -125,25 +125,10 @@ private async Task FetchSpansAsync(
return ExitCodeConstants.InvalidCommand;
}
- // Build query string with multiple resource parameters
- var additionalParams = new List<(string key, string? value)>
- {
- ("traceId", traceId)
- };
- if (hasError.HasValue)
- {
- additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant()));
- }
- if (limit.HasValue && !follow)
- {
- additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
- }
- if (follow)
- {
- additionalParams.Add(("follow", "true"));
- }
+ // Build URL with query parameters
+ int? effectiveLimit = (limit.HasValue && !follow) ? limit.Value : null;
- var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
+ var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, traceId: traceId, hasError: hasError, limit: effectiveLimit, follow: follow ? true : null);
_logger.LogDebug("Fetching spans from {Url}", url);
diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
index b97018fd7e5..3159c54be22 100644
--- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
@@ -188,18 +188,7 @@ private async Task FetchTracesAsync(
// Pre-resolve colors so assignment is deterministic regardless of data order
TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources);
- // Build query string with multiple resource parameters
- var additionalParams = new List<(string key, string? value)>();
- if (hasError.HasValue)
- {
- additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant()));
- }
- if (limit.HasValue)
- {
- additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
- }
-
- var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
+ var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, hasError: hasError, limit: limit);
_logger.LogDebug("Fetching traces from {Url}", url);
diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
index 4df3c286b4d..fb6bb671980 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
@@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co
};
}
- var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources);
+ // Fetch all logs from the API. Limiting of returned telemetry to the MCP caller happens later.
+ var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit);
logger.LogDebug("Fetching structured logs from {Url}", url);
diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
index 87320996e7f..f7543ec2adb 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
@@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co
var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false);
// Build the logs API URL with traceId filter
- var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resources: null, ("traceId", traceId));
+ // Fetch all logs for the trace from the API. Limiting of returned telemetry to the MCP caller happens later.
+ var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, traceId: traceId, limit: TelemetryCommandHelpers.MaxTelemetryLimit);
logger.LogDebug("Fetching structured logs from {Url}", url);
diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
index 187b8479a7f..bdb96f204bf 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
@@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co
};
}
- var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources);
+ // Fetch all traces from the API. Limiting of returned telemetry to the MCP caller happens later.
+ var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit);
logger.LogDebug("Fetching traces from {Url}", url);
diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs
index da509a0be25..a4c86192d28 100644
--- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs
+++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs
@@ -77,16 +77,17 @@ private static Process StartWindows(string fileName, IReadOnlyList argum
var si = new STARTUPINFOEX();
si.cb = Marshal.SizeOf();
- si.dwFlags = StartfUseStdHandles;
+ si.dwFlags = StartfUseStdHandles | StartfUseShowWindow;
si.hStdInput = nint.Zero;
si.hStdOutput = nulRawHandle;
si.hStdError = nulRawHandle;
si.lpAttributeList = attrList;
+ si.wShowWindow = ShowWindowHide;
// Build the command line string: "fileName" arg1 arg2 ...
var commandLine = BuildCommandLine(fileName, arguments);
- var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow;
+ var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNewProcessGroup;
if (!CreateProcessW(
null,
@@ -223,9 +224,11 @@ private static void AppendArgument(StringBuilder sb, string argument)
private const uint OpenExisting = 3;
private const uint HandleFlagInherit = 0x00000001;
private const uint StartfUseStdHandles = 0x00000100;
+ private const uint StartfUseShowWindow = 0x00000001;
private const uint CreateUnicodeEnvironment = 0x00000400;
private const uint ExtendedStartupInfoPresent = 0x00080000;
- private const uint CreateNoWindow = 0x08000000;
+ private const uint CreateNewProcessGroup = 0x00000200;
+ private const ushort ShowWindowHide = 0x0000;
private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002;
// --- Structs ---
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index dc5fa35a5c9..d8f11d9e37a 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -5,6 +5,7 @@
using System.CommandLine.Parsing;
using System.Diagnostics;
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Agents;
@@ -662,6 +663,13 @@ public static async Task Main(string[] args)
cts.Cancel();
eventArgs.Cancel = true;
};
+ using var sigTermRegistration = OperatingSystem.IsWindows()
+ ? null
+ : PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
+ {
+ cts.Cancel();
+ context.Cancel = true;
+ });
Console.OutputEncoding = Encoding.UTF8;
@@ -674,6 +682,10 @@ public static async Task Main(string[] args)
logger.LogInformation("Version: {Version}", AspireCliTelemetry.GetCliVersion());
logger.LogInformation("Build ID: {BuildId}", AspireCliTelemetry.GetCliBuildId());
logger.LogInformation("Working directory: {WorkingDirectory}", Environment.CurrentDirectory);
+ // Logging the log file path is useful so that when console logging is enabled (for example with --log-level debug),
+ // the path is written to the console logger (stderr) for easier discovery.
+ logger.LogInformation("Log file: {LogFilePath}", loggingOptions.LogFilePath);
+ logger.LogInformation("CLI process ID: {ProcessId}", Environment.ProcessId);
IHost? app = null;
try
diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs
index 80617479ca7..76df9a7317a 100644
--- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs
+++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs
@@ -21,7 +21,6 @@ internal sealed class TelemetryApiService(
{
private const int DefaultLimit = 200;
private const int DefaultTraceLimit = 100;
- private const int MaxQueryCount = 10000;
private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray();
@@ -50,7 +49,7 @@ internal sealed class TelemetryApiService(
{
ResourceKey = resourceKey,
StartIndex = 0,
- Count = MaxQueryCount,
+ Count = int.MaxValue,
Filters = [],
FilterText = string.Empty
});
@@ -121,7 +120,7 @@ internal sealed class TelemetryApiService(
{
ResourceKey = resourceKey,
StartIndex = 0,
- Count = MaxQueryCount,
+ Count = int.MaxValue,
Filters = [],
FilterText = string.Empty
});
@@ -237,7 +236,7 @@ internal sealed class TelemetryApiService(
{
ResourceKey = resourceKey,
StartIndex = 0,
- Count = MaxQueryCount,
+ Count = int.MaxValue,
Filters = filters
});
allLogs.AddRange(result.Items);
diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs
index 17956d932f5..4a43552c4f6 100644
--- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs
@@ -495,24 +495,31 @@ public static IResourceBuilder WithReference(this IResourc
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery))
{
- // Use the endpoint's scheme (not name) in the service discovery key so that
- // .NET service discovery can correctly match the scheme segment to the URI scheme.
- var scheme = port.TargetEndpoint.Scheme;
- if (!schemeIndexTracker.TryGetValue(scheme, out var index))
+ // Use the endpoint's scheme for "http" and "https" endpoint names to handle
+ // TLS upgrades correctly. For all other endpoint names, use the endpoint name
+ // so that .NET service discovery's named endpoint resolution can match them.
+ var schemeKey = port.TargetEndpoint.EndpointName;
+ if (string.Equals(port.TargetEndpoint.EndpointName, "http", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(port.TargetEndpoint.EndpointName, "https", StringComparison.OrdinalIgnoreCase))
+ {
+ schemeKey = port.TargetEndpoint.Scheme;
+ }
+
+ if (!schemeIndexTracker.TryGetValue(schemeKey, out var index))
{
index = 0;
}
// Find the next unused index for this scheme in case of collisions with other callbacks.
- var key = $"services__{serviceName}__{scheme}__{index}";
+ var key = $"services__{serviceName}__{schemeKey}__{index}";
while (context.EnvironmentVariables.ContainsKey(key))
{
index++;
- key = $"services__{serviceName}__{scheme}__{index}";
+ key = $"services__{serviceName}__{schemeKey}__{index}";
}
context.EnvironmentVariables[key] = port.TunnelEndpoint;
- schemeIndexTracker[scheme] = index + 1;
+ schemeIndexTracker[schemeKey] = index + 1;
}
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints))
diff --git a/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs b/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs
index ffb12583329..eae8cc3abc1 100644
--- a/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs
+++ b/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs
@@ -25,10 +25,25 @@ internal YarpCluster(ClusterConfig config, params object[] targets)
///
/// The endpoint to target.
internal YarpCluster(EndpointReference endpoint)
- : this(endpoint.Resource.Name, $"{endpoint.Scheme}://_{endpoint.EndpointName}.{endpoint.Resource.Name}")
+ : this(endpoint.Resource.Name, BuildEndpointTarget(endpoint))
{
}
+ private static string BuildEndpointTarget(EndpointReference endpoint)
+ {
+ // For endpoints named "http" or "https", use standard resolution without
+ // the named endpoint prefix since the scheme key already matches the scheme.
+ // For all other endpoint names, use the named endpoint DNS-SD convention
+ // which .NET service discovery resolves via the endpoint name in the config key.
+ if (string.Equals(endpoint.EndpointName, "http", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(endpoint.EndpointName, "https", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{endpoint.Scheme}://{endpoint.Resource.Name}";
+ }
+
+ return $"{endpoint.Scheme}://_{endpoint.EndpointName}.{endpoint.Resource.Name}";
+ }
+
///
/// Construct a new YarpCluster targeting the resource in parameter.
///
diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs
index 9ced43243de..40d07437a02 100644
--- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs
+++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs
@@ -69,6 +69,15 @@ public sealed class EndpointReference : IManifestExpressionProvider, IValueProvi
///
public bool TlsEnabled => Exists && EndpointAnnotation.TlsEnabled;
+ ///
+ /// Gets a value indicating whether the endpoint name is "http" or "https", ignoring case. This is a convention used to identify
+ /// endpoints that will be resolved based on the scheme of the endpoint in service discovery rather than by the specific endpoint name.
+ /// This is done to allow http endpoints that are dynamically updated to https to be mapped correctly despite the endpoint name no longer
+ /// matching the scheme.
+ ///
+ internal bool IsHttpSchemeNamedEndpoint => string.Equals(EndpointName, "http", StringComparisons.EndpointAnnotationUriScheme) ||
+ string.Equals(EndpointName, "https", StringComparisons.EndpointAnnotationUriScheme);
+
///
/// Gets a value indicating whether this endpoint is excluded from the default set when referencing the resource's endpoints.
///
diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj
index 20d5c5be4c0..c16d13c6254 100644
--- a/src/Aspire.Hosting/Aspire.Hosting.csproj
+++ b/src/Aspire.Hosting/Aspire.Hosting.csproj
@@ -53,6 +53,7 @@
+
diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
index 95d5b856976..2f260d7d035 100644
--- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
+++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
@@ -353,13 +353,20 @@ public Task GetAppHostInformationAsync(CancellationToken can
{
cliProcessId = parsedCliPid;
}
+ DateTimeOffset? cliStartedAt = null;
+ var cliStartedAtString = configuration[KnownConfigNames.CliProcessStarted];
+ if (!string.IsNullOrEmpty(cliStartedAtString) && long.TryParse(cliStartedAtString, out var parsedCliStartedAt))
+ {
+ cliStartedAt = DateTimeOffset.FromUnixTimeSeconds(parsedCliStartedAt);
+ }
return Task.FromResult(new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = Environment.ProcessId,
CliProcessId = cliProcessId,
- StartedAt = new DateTimeOffset(Process.GetCurrentProcess().StartTime)
+ StartedAt = new DateTimeOffset(Process.GetCurrentProcess().StartTime),
+ CliStartedAt = cliStartedAt
});
}
diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
index 1bf4303c6cc..06f2ef3aa5f 100644
--- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
+++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
@@ -961,6 +961,12 @@ internal sealed class AppHostInformation
/// Gets or sets when the AppHost process started.
///
public DateTimeOffset? StartedAt { get; init; }
+
+ ///
+ /// Gets or sets when the CLI process that launched the AppHost started.
+ /// This value is only set when the AppHost is launched via the Aspire CLI.
+ ///
+ public DateTimeOffset? CliStartedAt { get; init; }
}
///
diff --git a/src/Aspire.Hosting/Cli/CliOrphanDetector.cs b/src/Aspire.Hosting/Cli/CliOrphanDetector.cs
index e7f65e88f39..d4b72afb02a 100644
--- a/src/Aspire.Hosting/Cli/CliOrphanDetector.cs
+++ b/src/Aspire.Hosting/Cli/CliOrphanDetector.cs
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -12,37 +11,14 @@ internal sealed class CliOrphanDetector(IConfiguration configuration, IHostAppli
{
internal Func IsProcessRunning { get; set; } = (int pid) =>
{
- try
- {
- return !Process.GetProcessById(pid).HasExited;
- }
- catch (ArgumentException)
- {
- // If Process.GetProcessById throws it means the process in not running.
- return false;
- }
+ using var process = ProcessSignaler.TryGetRunningProcess(pid, null, logger);
+ return process is not null;
};
internal Func IsProcessRunningWithStartTime { get; set; } = (int pid, long expectedStartTimeUnix) =>
{
- try
- {
- var process = Process.GetProcessById(pid);
- if (process.HasExited)
- {
- return false;
- }
-
- // Check if the process start time matches the expected start time exactly.
- var actualStartTimeUnix = ((DateTimeOffset)process.StartTime).ToUnixTimeSeconds();
- return actualStartTimeUnix == expectedStartTimeUnix;
- }
- catch
- {
- // If we can't get the process and/or can't get the start time,
- // then we interpret both exceptions as the process not being there.
- return false;
- }
+ using var process = ProcessSignaler.TryGetRunningProcess(pid, DateTimeOffset.FromUnixTimeSeconds(expectedStartTimeUnix), logger);
+ return process is not null;
};
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index 9c47ee8ea3f..e4ea9c57b69 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -596,24 +596,25 @@ private static Action CreateEndpointReferenceEnviron
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery))
{
- // Use the endpoint's scheme (not name) in the service discovery key so that
- // .NET service discovery can correctly match the scheme segment to the URI scheme.
- var scheme = endpoint.Scheme;
- if (!schemeIndexTracker.TryGetValue(scheme, out var index))
+ // Use the endpoint's scheme for "http" and "https" endpoint names to handle
+ // TLS upgrades correctly. For all other endpoint names, use the endpoint name
+ // so that .NET service discovery's named endpoint resolution can match them.
+ var schemeKey = endpoint.IsHttpSchemeNamedEndpoint ? endpoint.Scheme : endpointName;
+ if (!schemeIndexTracker.TryGetValue(schemeKey, out var index))
{
index = 0;
}
// Find the next unused index for this scheme in case of collisions with other callbacks.
- var key = $"services__{serviceName}__{scheme}__{index}";
+ var key = $"services__{serviceName}__{schemeKey}__{index}";
while (context.EnvironmentVariables.ContainsKey(key))
{
index++;
- key = $"services__{serviceName}__{scheme}__{index}";
+ key = $"services__{serviceName}__{schemeKey}__{index}";
}
context.EnvironmentVariables[key] = endpoint;
- schemeIndexTracker[scheme] = index + 1;
+ schemeIndexTracker[schemeKey] = index + 1;
}
}
};
diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs
index 597f9f25b3b..0437cfcdcc6 100644
--- a/src/Shared/DashboardUrls.cs
+++ b/src/Shared/DashboardUrls.cs
@@ -193,42 +193,92 @@ public static string CombineUrl(string baseUrl, string path)
private const string TelemetryApiBasePath = "api/telemetry";
///
- /// Builds the URL for the telemetry logs API with resource filtering.
+ /// Builds the URL for the telemetry logs API.
///
/// The dashboard base URL.
/// Optional list of resource names to filter by.
- /// Additional query parameters.
+ /// Optional trace ID to filter logs by.
+ /// Optional minimum severity level filter.
+ /// Optional maximum number of results to return.
+ /// Optional flag to enable streaming mode.
/// The full API URL.
- public static string TelemetryLogsApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams)
+ public static string TelemetryLogsApiUrl(string baseUrl, List? resources = null, string? traceId = null, string? severity = null, int? limit = null, bool? follow = null)
{
- var queryString = BuildResourceQueryString(resources, additionalParams);
- return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/logs{queryString}");
+ var url = $"/{TelemetryApiBasePath}/logs";
+ url = AddResourceParams(url, resources);
+ if (traceId is not null)
+ {
+ url = AddQueryString(url, "traceId", traceId);
+ }
+ if (severity is not null)
+ {
+ url = AddQueryString(url, "severity", severity);
+ }
+ if (limit is not null)
+ {
+ url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture));
+ }
+ if (follow == true)
+ {
+ url = AddQueryString(url, "follow", "true");
+ }
+ return CombineUrl(baseUrl, url);
}
///
- /// Builds the URL for the telemetry spans API with resource filtering.
+ /// Builds the URL for the telemetry spans API.
///
/// The dashboard base URL.
/// Optional list of resource names to filter by.
- /// Additional query parameters.
+ /// Optional trace ID to filter spans by.
+ /// Optional filter for error status.
+ /// Optional maximum number of results to return.
+ /// Optional flag to enable streaming mode.
/// The full API URL.
- public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams)
+ public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, string? traceId = null, bool? hasError = null, int? limit = null, bool? follow = null)
{
- var queryString = BuildResourceQueryString(resources, additionalParams);
- return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/spans{queryString}");
+ var url = $"/{TelemetryApiBasePath}/spans";
+ url = AddResourceParams(url, resources);
+ if (traceId is not null)
+ {
+ url = AddQueryString(url, "traceId", traceId);
+ }
+ if (hasError is not null)
+ {
+ url = AddQueryString(url, "hasError", hasError.Value.ToString().ToLowerInvariant());
+ }
+ if (limit is not null)
+ {
+ url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture));
+ }
+ if (follow == true)
+ {
+ url = AddQueryString(url, "follow", "true");
+ }
+ return CombineUrl(baseUrl, url);
}
///
- /// Builds the URL for the telemetry traces API with resource filtering.
+ /// Builds the URL for the telemetry traces API.
///
/// The dashboard base URL.
/// Optional list of resource names to filter by.
- /// Additional query parameters.
+ /// Optional filter for error status.
+ /// Optional maximum number of results to return.
/// The full API URL.
- public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams)
+ public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, bool? hasError = null, int? limit = null)
{
- var queryString = BuildResourceQueryString(resources, additionalParams);
- return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/traces{queryString}");
+ var url = $"/{TelemetryApiBasePath}/traces";
+ url = AddResourceParams(url, resources);
+ if (hasError is not null)
+ {
+ url = AddQueryString(url, "hasError", hasError.Value.ToString().ToLowerInvariant());
+ }
+ if (limit is not null)
+ {
+ url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture));
+ }
+ return CombineUrl(baseUrl, url);
}
///
@@ -255,33 +305,18 @@ public static string TelemetryResourcesApiUrl(string baseUrl)
}
///
- /// Builds a query string with multiple resource parameters and optional additional parameters.
+ /// Appends multiple resource query parameters to a URL.
///
- internal static string BuildResourceQueryString(
- List? resources,
- params (string key, string? value)[] additionalParams)
+ private static string AddResourceParams(string url, List? resources)
{
- var parts = new List();
-
- // Add each resource as a separate query parameter
if (resources is not null)
{
foreach (var resource in resources)
{
- parts.Add($"resource={Uri.EscapeDataString(resource)}");
+ url = AddQueryString(url, "resource", resource);
}
}
-
- // Add additional parameters
- foreach (var (key, value) in additionalParams)
- {
- if (!string.IsNullOrEmpty(value))
- {
- parts.Add($"{key}={Uri.EscapeDataString(value)}");
- }
- }
-
- return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
+ return url;
}
#endregion
diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs
new file mode 100644
index 00000000000..93cf21a85bd
--- /dev/null
+++ b/src/Shared/ProcessSignaler.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Logging;
+
+///
+/// Provides best-effort process signaling for graceful shutdown and forceful termination.
+///
+internal static partial class ProcessSignaler
+{
+ public static void RequestGracefulShutdown(int pid, DateTimeOffset? expectedStartTime, ILogger logger)
+ {
+ using var process = TryGetRunningProcess(pid, expectedStartTime, logger);
+ if (process is null)
+ {
+ return; // Process is not running or does not match the expected start time
+ }
+
+ logger.LogDebug("Requesting graceful shutdown of process {Pid}...", pid);
+
+ if (OperatingSystem.IsWindows())
+ {
+ RequestGracefulShutdownWindows(pid, logger);
+ }
+ else
+ {
+ RequestGracefulShutdownUnix(pid, logger);
+ }
+ }
+
+ public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger logger)
+ {
+ using var process = TryGetRunningProcess(pid, expectedStartTime, logger);
+ if (process is { })
+ {
+ logger.LogDebug("Killing process {Pid}...", pid);
+ try
+ {
+ process.Kill(entireProcessTree: false);
+ }
+ catch (InvalidOperationException)
+ {
+ // Process already exited.
+ }
+ }
+ }
+
+ public static Process? TryGetRunningProcess(int pid, DateTimeOffset? expectedStartTime, ILogger logger)
+ {
+ try
+ {
+ var process = Process.GetProcessById(pid);
+ if (expectedStartTime is not null && !AreClose(expectedStartTime, process.StartTime))
+ {
+ logger.LogDebug("Process {Pid} start time {ProcessStartTime} does not match expected start time {ExpectedStartTime}", pid, process.StartTime, expectedStartTime);
+ process.Dispose();
+ return null; // Do not return processes that do not match the expected start time
+ }
+
+ if (process.HasExited)
+ {
+ process.Dispose();
+ return null;
+ }
+
+ return process;
+ }
+ catch (ArgumentException)
+ {
+ // Process doesn't exist - already terminated.
+ return null;
+ }
+ catch (InvalidOperationException)
+ {
+ // Process has already exited.
+ return null;
+ }
+ }
+
+ private static bool AreClose(DateTimeOffset? expectedStartTime, DateTime processStartTime, TimeSpan? tolerance = default)
+ {
+ if (expectedStartTime is null)
+ {
+ return true;
+ }
+
+ tolerance ??= TimeSpan.FromSeconds(1);
+ return ((DateTimeOffset)expectedStartTime - new DateTimeOffset(processStartTime)).Duration() <= tolerance;
+ }
+
+ private const int SigTerm = 15;
+
+ private static void RequestGracefulShutdownUnix(int pid, ILogger logger)
+ {
+ var result = kill(pid, SigTerm);
+ if (result != 0)
+ {
+ int errno = Marshal.GetLastSystemError();
+ // Best effort.
+ logger.LogWarning("Could not gracefully stop Aspire application host process {Pid}; the error code from signal send operation was {ErrorCode}", pid, errno);
+ }
+ }
+
+ private const uint CtrlBreakEvent = 1;
+
+ private static void RequestGracefulShutdownWindows(int pid, ILogger logger)
+ {
+ var success = GenerateConsoleCtrlEvent(CtrlBreakEvent, (uint)pid);
+ if (!success)
+ {
+ // Best effort.
+ logger.LogWarning("Could not gracefully stop Aspire application host process {Pid}", pid);
+ }
+ }
+
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
+
+ // "libc" here is a moniker for standard C library, which .NET maps to system C library on Unix-like systems.
+ // See https://developers.redhat.com/blog/2019/03/25/using-net-pinvoke-for-linux-system-functions
+ [LibraryImport("libc", SetLastError = true, EntryPoint = "kill")]
+ private static partial int kill(int pid, int sig);
+}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs
new file mode 100644
index 00000000000..396792aa789
--- /dev/null
+++ b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs
@@ -0,0 +1,166 @@
+// 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 Aspire.TestUtilities;
+using Xunit;
+
+namespace Aspire.Cli.EndToEnd.Tests;
+
+///
+/// End-to-end test verifying that the aspire new flow with AI agent initialization
+/// completes without provenance verification errors when installing @playwright/cli.
+///
+[OuterloopTest("Requires npm and network access to install @playwright/cli from the npm registry")]
+public sealed class NewWithAgentInitTests(ITestOutputHelper output)
+{
+ ///
+ /// Exercises the full aspire new → agent init → Playwright CLI install flow end-to-end.
+ /// This is the primary regression test for provenance verification failures (e.g., tag format changes
+ /// in upstream @playwright/cli releases).
+ ///
+ /// The test:
+ /// 1. Runs aspire new to create a Starter project
+ /// 2. Accepts the agent init prompt (instead of declining)
+ /// 3. Selects Playwright CLI during skill selection
+ /// 4. Verifies no errors appear (especially no "Provenance verification failed")
+ /// 5. Verifies playwright-cli is installed and skill files are generated
+ ///
+ [Fact]
+ public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors()
+ {
+ var repoRoot = CliE2ETestHelpers.GetRepoRoot();
+ var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
+ var workspace = TemporaryWorkspace.Create(output);
+
+ using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace);
+
+ var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
+
+ var counter = new SequenceCounter();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliInDockerAsync(installMode, counter);
+
+ // Create .claude folder so agent init detects a Claude Code environment.
+ // This needs to exist in the workspace root before aspire new creates the project
+ // because agent init chains after project creation and looks for environment markers.
+ await auto.TypeAsync("mkdir -p .claude");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Run aspire new with the Starter template, going through all prompts manually
+ // so we can ACCEPT the agent init prompt instead of declining it.
+ await auto.TypeAsync("aspire new");
+ await auto.EnterAsync();
+
+ // Template selection: accept default Starter App
+ await auto.WaitUntilAsync(
+ s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(60),
+ description: "template selection list (> Starter App)");
+ await auto.EnterAsync();
+
+ // Project name
+ 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("StarterApp");
+ await auto.EnterAsync();
+
+ // Output path: accept default
+ await auto.WaitUntilAsync(
+ s => new CellPatternSearcher().Find("Enter the output path").Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "output path prompt");
+ await auto.EnterAsync();
+
+ // URLs prompt: accept default No
+ 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();
+
+ // Redis cache: accept default Yes
+ await auto.WaitUntilAsync(
+ s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "Redis cache prompt");
+ await auto.EnterAsync();
+
+ // Test project: accept default No
+ await auto.WaitUntilAsync(
+ s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "test project prompt");
+ await auto.EnterAsync();
+
+ // Agent init prompt: ACCEPT it (type 'y')
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("configure AI agent environments"),
+ timeout: TimeSpan.FromSeconds(120),
+ description: "agent init prompt after aspire new");
+ await auto.WaitAsync(500);
+ await auto.TypeAsync("y");
+ await auto.EnterAsync();
+
+ // Agent init: workspace path - accept default
+ await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30));
+ await auto.WaitAsync(500);
+ await auto.EnterAsync();
+
+ // Agent init: skill location - select Claude Code
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("skill files be installed"),
+ timeout: TimeSpan.FromSeconds(60),
+ description: "skill location prompt");
+ await auto.TypeAsync(" "); // Toggle off default Standard location
+ await auto.DownAsync();
+ await auto.TypeAsync(" "); // Toggle on Claude Code location
+ await auto.EnterAsync();
+
+ // Agent init: skill selection - toggle on Playwright CLI
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("skills should be installed"),
+ timeout: TimeSpan.FromSeconds(30),
+ description: "skill selection prompt");
+ await auto.DownAsync();
+ await auto.TypeAsync(" "); // Toggle on Playwright CLI
+ await auto.EnterAsync();
+
+ // Wait for agent init to complete (downloads @playwright/cli from npm).
+ // Fail the test immediately if a provenance verification error appears.
+ await auto.WaitUntilAsync(s =>
+ {
+ if (s.ContainsText("Provenance verification failed"))
+ {
+ throw new InvalidOperationException(
+ "Provenance verification failed for @playwright/cli! " +
+ "This likely means the upstream package changed its tag format.");
+ }
+ return s.ContainsText("configuration complete");
+ }, timeout: TimeSpan.FromMinutes(5), description: "agent init configuration complete (no provenance errors)");
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Verify playwright-cli is installed and functional.
+ await auto.TypeAsync("playwright-cli --version");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ // Verify skill file was generated in the Claude Code location.
+ await auto.TypeAsync("ls StarterApp/.claude/skills/playwright-cli/SKILL.md");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10));
+ await auto.WaitForSuccessPromptAsync(counter);
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+
+ await pendingRun;
+ }
+}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
index 5c896edf4d8..d35b375095e 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs
@@ -20,6 +20,8 @@ public async Task CreateStartAndStopAspireProject()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
+ var projectSuffix = Guid.NewGuid().ToString("N")[..6];
+ var projectName = $"StarterApp_{projectSuffix}";
var workspace = TemporaryWorkspace.Create(output);
@@ -37,10 +39,10 @@ public async Task CreateStartAndStopAspireProject()
await auto.InstallAspireCliInDockerAsync(installMode, counter);
// Create a new project using aspire new
- await auto.AspireNewAsync("AspireStarterApp", counter);
+ await auto.AspireNewAsync(projectName, counter);
// Navigate to the AppHost directory
- await auto.TypeAsync("cd AspireStarterApp/AspireStarterApp.AppHost");
+ await auto.TypeAsync($"cd {projectName}/{projectName}.AppHost");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
@@ -56,6 +58,11 @@ public async Task CreateStartAndStopAspireProject()
await auto.WaitUntilTextAsync(StopCommandStrings.AppHostStoppedSuccessfully, timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
+ await auto.ClearScreenAsync(counter);
+
+ // Ensure the test-specific Docker network is cleaned up (which signifies end of container cleanup)
+ await auto.ExecuteCommandUntilOutputAsync(counter, $"docker network ls --format json | grep -i -- '{projectName}' | wc -l", "0", timeout: TimeSpan.FromMinutes(2));
+
// Exit the shell
await auto.TypeAsync("exit");
await auto.EnterAsync();
diff --git a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs
index 12be67ebe90..49427e7d5f0 100644
--- a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs
+++ b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs
@@ -343,6 +343,40 @@ public void VerifyIntegrity_WithNonSha512Prefix_ReturnsFalse()
}
}
+ [Fact]
+ public async Task InstallAsync_WorkflowRefValidator_AcceptsBothTagFormats()
+ {
+ var version = SemVersion.Parse("0.1.7", SemVersionStyles.Strict);
+ var npmRunner = new TestNpmRunner
+ {
+ ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
+ };
+ var provenanceChecker = new TestNpmProvenanceChecker();
+ var playwrightRunner = new TestPlaywrightCliRunner();
+ var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance);
+
+ await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
+
+ Assert.True(provenanceChecker.ProvenanceCalled);
+ Assert.NotNull(provenanceChecker.CapturedValidateWorkflowRef);
+
+ // Accept tags without 'v' prefix (0.1.7+)
+ Assert.True(WorkflowRefInfo.TryParse($"refs/tags/{version}", out var refWithout));
+ Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWithout!));
+
+ // Accept tags with 'v' prefix (pre-0.1.7)
+ Assert.True(WorkflowRefInfo.TryParse($"refs/tags/v{version}", out var refWith));
+ Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWith!));
+
+ // Reject wrong version
+ Assert.True(WorkflowRefInfo.TryParse("refs/tags/0.2.0", out var wrongVersion));
+ Assert.False(provenanceChecker.CapturedValidateWorkflowRef(wrongVersion!));
+
+ // Reject branch ref (not a tag)
+ Assert.True(WorkflowRefInfo.TryParse("refs/heads/main", out var branchRef));
+ Assert.False(provenanceChecker.CapturedValidateWorkflowRef(branchRef!));
+ }
+
[Fact]
public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse()
{
@@ -577,10 +611,12 @@ private sealed class TestNpmProvenanceChecker : INpmProvenanceChecker
{
public ProvenanceVerificationOutcome ProvenanceOutcome { get; set; } = ProvenanceVerificationOutcome.Verified;
public bool ProvenanceCalled { get; private set; }
+ public Func? CapturedValidateWorkflowRef { get; private set; }
public Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null)
{
ProvenanceCalled = true;
+ CapturedValidateWorkflowRef = validateWorkflowRef;
return Task.FromResult(new ProvenanceVerificationResult
{
Outcome = ProvenanceOutcome,
diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs
index 205619fc810..5411b9ded3b 100644
--- a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs
@@ -29,51 +29,45 @@ public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand()
}
[Fact]
- public void BuildResourceQueryString_WithNoResources_ReturnsEmptyString()
+ public void TelemetryLogsApiUrl_WithNoParams_ReturnsBaseUrl()
{
- var result = DashboardUrls.BuildResourceQueryString(null);
- Assert.Equal("", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000");
+ Assert.Equal("https://localhost:5000/api/telemetry/logs", result);
}
[Fact]
- public void BuildResourceQueryString_WithSingleResource_ReturnsCorrectQueryString()
+ public void TelemetryLogsApiUrl_WithSingleResource_ReturnsCorrectUrl()
{
- var result = DashboardUrls.BuildResourceQueryString(["frontend"]);
- Assert.Equal("?resource=frontend", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"]);
+ Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend", result);
}
[Fact]
- public void BuildResourceQueryString_WithMultipleResources_ReturnsAllResourceParams()
+ public void TelemetryLogsApiUrl_WithMultipleResources_ReturnsAllResourceParams()
{
- var result = DashboardUrls.BuildResourceQueryString(["frontend-abc123", "frontend-xyz789"]);
- Assert.Equal("?resource=frontend-abc123&resource=frontend-xyz789", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend-abc123", "frontend-xyz789"]);
+ Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend-abc123&resource=frontend-xyz789", result);
}
[Fact]
- public void BuildResourceQueryString_WithResourcesAndAdditionalParams_CombinesCorrectly()
+ public void TelemetryLogsApiUrl_WithAllParams_CombinesCorrectly()
{
- var result = DashboardUrls.BuildResourceQueryString(
- ["frontend"],
- ("traceId", "abc123"),
- ("limit", "10"));
- Assert.Equal("?resource=frontend&traceId=abc123&limit=10", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"], traceId: "abc123", severity: "Error", limit: 10, follow: true);
+ Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend&traceId=abc123&severity=Error&limit=10&follow=true", result);
}
[Fact]
- public void BuildResourceQueryString_WithNullAdditionalParams_SkipsNullValues()
+ public void TelemetryLogsApiUrl_WithNullParams_SkipsNullValues()
{
- var result = DashboardUrls.BuildResourceQueryString(
- ["frontend"],
- ("traceId", null),
- ("limit", "10"));
- Assert.Equal("?resource=frontend&limit=10", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"], traceId: null, limit: 10);
+ Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend&limit=10", result);
}
[Fact]
- public void BuildResourceQueryString_WithSpecialCharacters_EncodesCorrectly()
+ public void TelemetryLogsApiUrl_WithSpecialCharacters_EncodesCorrectly()
{
- var result = DashboardUrls.BuildResourceQueryString(["service with spaces"]);
- Assert.Equal("?resource=service%20with%20spaces", result);
+ var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["service with spaces"]);
+ Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=service%20with%20spaces", result);
}
[Fact]
diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs
index 15d6995796c..adf88d43af7 100644
--- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs
@@ -238,7 +238,7 @@ public async Task GetSpans_WithQueryParameters_Returns200()
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}");
// Act - test query parameters without resource filter (no resources exist in test)
- var response = await httpClient.GetAsync("/api/telemetry/spans?hasError=true&limit=50").DefaultTimeout();
+ var response = await httpClient.GetAsync($"/api/telemetry/spans?hasError=true&limit={int.MaxValue}").DefaultTimeout();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -278,7 +278,7 @@ public async Task GetLogs_WithQueryParameters_Returns200()
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}");
// Act - test query parameters without resource filter (no resources exist in test)
- var response = await httpClient.GetAsync("/api/telemetry/logs?severity=Error&limit=50").DefaultTimeout();
+ var response = await httpClient.GetAsync($"/api/telemetry/logs?severity=Error&limit={int.MaxValue}").DefaultTimeout();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs
index 8bd11576896..4e3138be183 100644
--- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs
+++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs
@@ -453,6 +453,164 @@ public void GetTrace_VariousTraceIds_ReturnsExpectedResult(string lookupId, bool
}
}
+ [Fact]
+ public void GetSpans_WithLimit_ReturnsMostRecentSpans()
+ {
+ var repository = CreateRepository();
+
+ repository.AddTraces(new AddContext(), new RepeatedField
+ {
+ new ResourceSpans
+ {
+ Resource = CreateResource(name: "service1", instanceId: "inst1"),
+ ScopeSpans =
+ {
+ new ScopeSpans
+ {
+ Scope = CreateScope(),
+ Spans =
+ {
+ CreateSpan(traceId: "trace1", spanId: "old-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)),
+ CreateSpan(traceId: "trace2", spanId: "mid-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)),
+ CreateSpan(traceId: "trace3", spanId: "new-span", startTime: s_testTime.AddMinutes(4), endTime: s_testTime.AddMinutes(5))
+ }
+ }
+ }
+ }
+ });
+
+ var service = CreateService(repository);
+
+ var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: 2);
+
+ Assert.NotNull(result);
+ Assert.Equal(3, result.TotalCount);
+ Assert.Equal(2, result.ReturnedCount);
+
+ var json = System.Text.Json.JsonSerializer.Serialize(result.Data);
+ Assert.DoesNotContain("old-span", json);
+ Assert.Contains("mid-span", json);
+ Assert.Contains("new-span", json);
+ }
+
+ [Fact]
+ public void GetTraces_WithLimit_ReturnsMostRecentTraces()
+ {
+ var repository = CreateRepository();
+
+ for (var i = 1; i <= 3; i++)
+ {
+ repository.AddTraces(new AddContext(), new RepeatedField
+ {
+ new ResourceSpans
+ {
+ Resource = CreateResource(name: "service1", instanceId: "inst1"),
+ ScopeSpans =
+ {
+ new ScopeSpans
+ {
+ Scope = CreateScope(),
+ Spans =
+ {
+ CreateSpan(traceId: $"trace{i}", spanId: $"span{i}", startTime: s_testTime.AddMinutes(i * 10), endTime: s_testTime.AddMinutes(i * 10 + 1))
+ }
+ }
+ }
+ }
+ });
+ }
+
+ var service = CreateService(repository);
+
+ var result = service.GetTraces(resourceNames: null, hasError: null, limit: 2);
+
+ Assert.NotNull(result);
+ Assert.Equal(3, result.TotalCount);
+ Assert.Equal(2, result.ReturnedCount);
+
+ var json = System.Text.Json.JsonSerializer.Serialize(result.Data);
+ Assert.DoesNotContain("span1", json);
+ Assert.Contains("span2", json);
+ Assert.Contains("span3", json);
+ }
+
+ [Fact]
+ public void GetLogs_WithLimit_ReturnsMostRecentLogs()
+ {
+ var repository = CreateRepository();
+
+ repository.AddLogs(new AddContext(), new RepeatedField
+ {
+ new ResourceLogs
+ {
+ Resource = CreateResource(name: "service1", instanceId: "inst1"),
+ ScopeLogs =
+ {
+ new ScopeLogs
+ {
+ Scope = CreateScope("TestLogger"),
+ LogRecords =
+ {
+ CreateLogRecord(time: s_testTime, message: "old-log", severity: SeverityNumber.Info),
+ CreateLogRecord(time: s_testTime.AddMinutes(1), message: "mid-log", severity: SeverityNumber.Info),
+ CreateLogRecord(time: s_testTime.AddMinutes(2), message: "new-log", severity: SeverityNumber.Info)
+ }
+ }
+ }
+ }
+ });
+
+ var service = CreateService(repository);
+
+ var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: 2);
+
+ Assert.NotNull(result);
+ Assert.Equal(3, result.TotalCount);
+ Assert.Equal(2, result.ReturnedCount);
+
+ var json = System.Text.Json.JsonSerializer.Serialize(result.Data);
+ Assert.DoesNotContain("old-log", json);
+ Assert.Contains("mid-log", json);
+ Assert.Contains("new-log", json);
+ }
+
+ [Fact]
+ public void GetLogs_LargeLimit_ReturnsAllLogs()
+ {
+ const int totalLogs = 20_000;
+ var repository = CreateRepository(maxLogCount: totalLogs);
+
+ var logRecords = new RepeatedField();
+ for (var i = 0; i < totalLogs; i++)
+ {
+ logRecords.Add(CreateLogRecord(time: s_testTime.AddMilliseconds(i), message: $"log{i}", severity: SeverityNumber.Info));
+ }
+
+ repository.AddLogs(new AddContext(), new RepeatedField
+ {
+ new ResourceLogs
+ {
+ Resource = CreateResource(name: "service1", instanceId: "inst1"),
+ ScopeLogs =
+ {
+ new ScopeLogs
+ {
+ Scope = CreateScope("TestLogger"),
+ LogRecords = { logRecords }
+ }
+ }
+ }
+ });
+
+ var service = CreateService(repository);
+
+ var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: 100_000);
+
+ Assert.NotNull(result);
+ Assert.Equal(totalLogs, result.TotalCount);
+ Assert.Equal(totalLogs, result.ReturnedCount);
+ }
+
///
/// Creates a TelemetryApiService instance for testing with optional custom dependencies.
///
diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml
index 58de5541176..5fbf7692659 100644
--- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml
+++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml
@@ -33,13 +33,13 @@
services__project1__http__0: "http://project1:${PROJECT1_PORT}"
PROJECT1_HTTPS: "https://project1:${PROJECT1_PORT}"
PROJECT1_CUSTOM1: "http://project1:8000"
- services__project1__http__1: "http://project1:8000"
+ services__project1__custom1__0: "http://project1:8000"
PROJECT1_CUSTOM2: "http://project1:8001"
- services__project1__http__2: "http://project1:8001"
+ services__project1__custom2__0: "http://project1:8001"
PROJECT1_CUSTOM3: "http://project1:7002"
- services__project1__http__3: "http://project1:7002"
+ services__project1__custom3__0: "http://project1:7002"
PROJECT1_CUSTOM4: "http://project1:7004"
- services__project1__http__4: "http://project1:7004"
+ services__project1__custom4__0: "http://project1:7004"
networks:
- "aspire"
networks:
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml
index 70534489e39..4de9a259cf3 100644
--- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml
@@ -9,10 +9,10 @@ config:
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
api:
PROJECT1_CUSTOM1: "http://project1-service:8000"
- services__project1__http__1: "http://project1-service:8000"
+ services__project1__custom1__0: "http://project1-service:8000"
PROJECT1_CUSTOM2: "http://project1-service:8001"
- services__project1__http__2: "http://project1-service:8001"
+ services__project1__custom2__0: "http://project1-service:8001"
PROJECT1_CUSTOM3: "http://project1-service:7002"
- services__project1__http__3: "http://project1-service:7002"
+ services__project1__custom3__0: "http://project1-service:7002"
PROJECT1_CUSTOM4: "http://project1-service:7004"
- services__project1__http__4: "http://project1-service:7004"
+ services__project1__custom4__0: "http://project1-service:7004"
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml
index 1f6c4407daa..f691fa99a76 100644
--- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml
@@ -13,10 +13,10 @@ data:
PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_http }}"
services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_http }}"
PROJECT1_CUSTOM1: "{{ .Values.config.api.PROJECT1_CUSTOM1 }}"
- services__project1__http__1: "{{ .Values.config.api.services__project1__http__1 }}"
+ services__project1__custom1__0: "{{ .Values.config.api.services__project1__custom1__0 }}"
PROJECT1_CUSTOM2: "{{ .Values.config.api.PROJECT1_CUSTOM2 }}"
- services__project1__http__2: "{{ .Values.config.api.services__project1__http__2 }}"
+ services__project1__custom2__0: "{{ .Values.config.api.services__project1__custom2__0 }}"
PROJECT1_CUSTOM3: "{{ .Values.config.api.PROJECT1_CUSTOM3 }}"
- services__project1__http__3: "{{ .Values.config.api.services__project1__http__3 }}"
+ services__project1__custom3__0: "{{ .Values.config.api.services__project1__custom3__0 }}"
PROJECT1_CUSTOM4: "{{ .Values.config.api.PROJECT1_CUSTOM4 }}"
- services__project1__http__4: "{{ .Values.config.api.services__project1__http__4 }}"
+ services__project1__custom4__0: "{{ .Values.config.api.services__project1__custom4__0 }}"
diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs
index bbfc60d94fb..7ff239e70c0 100644
--- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs
+++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs
@@ -28,7 +28,7 @@ public async Task ResourceWithSingleEndpointProducesSimplifiedEnvironmentVariabl
// Call environment variable callbacks.
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]);
+ Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]);
Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]);
Assert.True(projectB.Resource.TryGetAnnotationsOfType(out var relationships));
@@ -51,9 +51,9 @@ public async Task ResourceNamesWithDashesAreEncodedInEnvironmentVariables()
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__project-a__https__0"]);
+ Assert.Equal("https://localhost:2000", config["services__project-a__mybinding__0"]);
Assert.Equal("https://localhost:2000", config["PROJECT_A_MYBINDING"]);
- Assert.DoesNotContain("services__project_a__https__0", config.Keys);
+ Assert.DoesNotContain("services__project_a__mybinding__0", config.Keys);
Assert.DoesNotContain("PROJECT-A_MYBINDING", config.Keys);
}
@@ -71,9 +71,9 @@ public async Task OverriddenServiceNamesAreEncodedInEnvironmentVariables()
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__custom-name__https__0"]);
+ Assert.Equal("https://localhost:2000", config["services__custom-name__mybinding__0"]);
Assert.Equal("https://localhost:2000", config["custom_name_MYBINDING"]);
- Assert.DoesNotContain("services__custom_name__https__0", config.Keys);
+ Assert.DoesNotContain("services__custom_name__mybinding__0", config.Keys);
Assert.DoesNotContain("custom-name_MYBINDING", config.Keys);
}
@@ -104,28 +104,28 @@ public async Task ResourceWithEndpointRespectsCustomEnvironmentVariableNaming(Re
switch (flags)
{
case ReferenceEnvironmentInjectionFlags.All:
- Assert.Equal("https://localhost:2000", config["services__custom__https__0"]);
+ Assert.Equal("https://localhost:2000", config["services__custom__mybinding__0"]);
Assert.Equal("https://localhost:2000", config["custom_MYBINDING"]);
break;
case ReferenceEnvironmentInjectionFlags.ConnectionProperties:
Assert.False(config.ContainsKey("custom_MYBINDING"));
- Assert.False(config.ContainsKey("services__custom__https__0"));
+ Assert.False(config.ContainsKey("services__custom__mybinding__0"));
break;
case ReferenceEnvironmentInjectionFlags.ConnectionString:
Assert.False(config.ContainsKey("custom_MYBINDING"));
- Assert.False(config.ContainsKey("services__custom__https__0"));
+ Assert.False(config.ContainsKey("services__custom__mybinding__0"));
break;
case ReferenceEnvironmentInjectionFlags.ServiceDiscovery:
Assert.False(config.ContainsKey("custom_MYBINDING"));
- Assert.True(config.ContainsKey("services__custom__https__0"));
+ Assert.True(config.ContainsKey("services__custom__mybinding__0"));
break;
case ReferenceEnvironmentInjectionFlags.Endpoints:
Assert.True(config.ContainsKey("custom_MYBINDING"));
- Assert.False(config.ContainsKey("services__custom__https__0"));
+ Assert.False(config.ContainsKey("services__custom__mybinding__0"));
break;
case ReferenceEnvironmentInjectionFlags.None:
Assert.False(config.ContainsKey("custom_MYBINDING"));
- Assert.False(config.ContainsKey("services__custom__https__0"));
+ Assert.False(config.ContainsKey("services__custom__mybinding__0"));
break;
}
}
@@ -150,8 +150,8 @@ public async Task ResourceWithConflictingEndpointsProducesFullyScopedEnvironment
// Call environment variable callbacks.
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]);
- Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]);
+ Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]);
+ Assert.Equal("https://localhost:3000", config["services__projecta__myconflictingbinding__0"]);
Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]);
Assert.Equal("https://localhost:3000", config["PROJECTA_MYCONFLICTINGBINDING"]);
@@ -178,8 +178,8 @@ public async Task ResourceWithNonConflictingEndpointsProducesAllVariantsOfEnviro
// Call environment variable callbacks.
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]);
- Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]);
+ Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]);
+ Assert.Equal("http://localhost:3000", config["services__projecta__mynonconflictingbinding__0"]);
Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]);
Assert.Equal("http://localhost:3000", config["PROJECTA_MYNONCONFLICTINGBINDING"]);
@@ -204,8 +204,8 @@ public async Task ResourceWithConflictingEndpointsProducesAllEnvironmentVariable
// Call environment variable callbacks.
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]);
- Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]);
+ Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]);
+ Assert.Equal("https://localhost:3000", config["services__projecta__mybinding2__0"]);
Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]);
Assert.Equal("https://localhost:3000", config["PROJECTA_MYBINDING2"]);
@@ -233,8 +233,8 @@ public async Task ResourceWithEndpointsProducesAllEnvironmentVariables()
// Call environment variable callbacks.
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
- Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]);
- Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]);
+ Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]);
+ Assert.Equal("http://localhost:3000", config["services__projecta__mybinding2__0"]);
Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]);
Assert.Equal("http://localhost:3000", config["PROJECTA_MYBINDING2"]);
@@ -822,11 +822,11 @@ public async Task ExcludedReferenceEndpointExcludedFromUseAllEndpoints()
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
// The "api" endpoint should be included (it's not excluded)
- Assert.Equal("http://localhost:2000", config["services__projecta__http__0"]);
+ Assert.Equal("http://localhost:2000", config["services__projecta__api__0"]);
Assert.Equal("http://localhost:2000", config["PROJECTA_API"]);
// The "management" endpoint should NOT be included (ExcludeReferenceEndpoint = true)
- Assert.False(config.ContainsKey("services__projecta__http__1"));
+ Assert.False(config.ContainsKey("services__projecta__management__0"));
Assert.False(config.ContainsKey("PROJECTA_MANAGEMENT"));
}
@@ -852,7 +852,7 @@ public async Task ExcludedReferenceEndpointCanBeReferencedExplicitly()
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
// The "management" endpoint should be included because it was explicitly referenced
- Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]);
+ Assert.Equal("http://localhost:3000", config["services__projecta__management__0"]);
Assert.Equal("http://localhost:3000", config["PROJECTA_MANAGEMENT"]);
// The "api" endpoint should NOT be included (wasn't referenced)
@@ -882,8 +882,8 @@ public async Task CombinedWithReferenceAndExplicitEndpointIncludesBoth()
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
// Both endpoints should be included
- Assert.Equal("http://localhost:2000", config["services__projecta__http__0"]);
- Assert.Equal("http://localhost:3000", config["services__projecta__http__1"]);
+ Assert.Equal("http://localhost:2000", config["services__projecta__api__0"]);
+ Assert.Equal("http://localhost:3000", config["services__projecta__management__0"]);
Assert.Equal("http://localhost:2000", config["PROJECTA_API"]);
Assert.Equal("http://localhost:3000", config["PROJECTA_MANAGEMENT"]);
}
@@ -930,6 +930,64 @@ public void EndpointReferenceReflectsExcludeReferenceEndpoint()
Assert.True(managementRef.ExcludeReferenceEndpoint);
}
+ [Fact]
+ public async Task HttpEndpointWithTlsUsesActualSchemeAsKey()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ // Endpoint named "http" but with https scheme (TLS upgrade)
+ var projectA = builder.AddProject("projecta")
+ .WithEndpoint(name: "http", scheme: "https", targetPort: 443)
+ .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 443));
+
+ var projectB = builder.AddProject("b").WithReference(projectA);
+
+ var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
+
+ // Endpoint named "http" but scheme is "https" — key should use actual scheme
+ Assert.Equal("https://localhost:443", config["services__projecta__https__0"]);
+ Assert.DoesNotContain("services__projecta__http__0", config.Keys);
+ }
+
+ [Fact]
+ public async Task MultipleEndpointsWithDifferentNamesProduceDistinctKeys()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var projectA = builder.AddProject("projecta")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080))
+ .WithEndpoint(name: "prometheus", scheme: "http", targetPort: 9090)
+ .WithEndpoint("prometheus", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 9090));
+
+ var projectB = builder.AddProject("b").WithReference(projectA);
+
+ var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
+
+ // "http" endpoint uses scheme key "http"; "prometheus" endpoint uses endpoint name
+ Assert.Equal("http://localhost:8080", config["services__projecta__http__0"]);
+ Assert.Equal("http://localhost:9090", config["services__projecta__prometheus__0"]);
+ }
+
+ [Fact]
+ public async Task PerEndpointNameOverridesServiceName()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var projectA = builder.AddProject("projecta")
+ .WithEndpoint(name: "data", scheme: "http", targetPort: 8080)
+ .WithEndpoint("data", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080));
+
+ var projectB = builder.AddProject("b")
+ .WithReference(projectA, "projecta-data")
+ .WithReference(projectA.GetEndpoint("data"));
+
+ var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
+
+ // Service name overridden via WithReference(resource, name), endpoint name as scheme key
+ Assert.Equal("http://localhost:8080", config["services__projecta-data__data__0"]);
+ }
+
private sealed class TestResourceWithConnectionStringAndServiceDiscovery(string name) : ContainerResource(name), IResourceWithConnectionString, IResourceWithServiceDiscovery
{
public string? ConnectionString { get; set; }
diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env
index 94a8bc0ab9a..f4aaf7198fb 100644
--- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env
+++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env
@@ -38,8 +38,8 @@ services:
REVERSEPROXY__ROUTES__route1__CLUSTERID: "cluster_backend"
REVERSEPROXY__ROUTES__route1__TRANSFORMS__0__PathRemovePrefix: "/api"
REVERSEPROXY__CLUSTERS__cluster_backend__METADATA__custom-metadata: "some-value"
- REVERSEPROXY__CLUSTERS__cluster_backend__DESTINATIONS__destination1__ADDRESS: "http://_http.backend"
- REVERSEPROXY__CLUSTERS__cluster_frontend__DESTINATIONS__destination1__ADDRESS: "http://_http.frontend"
+ REVERSEPROXY__CLUSTERS__cluster_backend__DESTINATIONS__destination1__ADDRESS: "http://backend"
+ REVERSEPROXY__CLUSTERS__cluster_frontend__DESTINATIONS__destination1__ADDRESS: "http://frontend"
services__backend__http__0: "http://backend:8080"
FRONTEND_HTTP: "http://frontend:8080"
services__frontend__http__0: "http://frontend:8080"
diff --git a/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs b/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs
index 63bac388d10..36a4bf15db5 100644
--- a/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs
+++ b/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs
@@ -49,10 +49,25 @@ public void Create_YarpCluster_From_Endpoints_Without_Names()
var httpsEndpoint = resource.GetEndpoint("https");
var httpCluster = new YarpCluster(httpEndpoint);
- Assert.Equal("http://_http.ServiceC", httpCluster.Targets[0]);
+ Assert.Equal("http://ServiceC", httpCluster.Targets[0]);
var httpsCluster = new YarpCluster(httpsEndpoint);
- Assert.Equal("https://_https.ServiceC", httpsCluster.Targets[0]);
+ Assert.Equal("https://ServiceC", httpsCluster.Targets[0]);
+ }
+
+ [Fact]
+ public void Create_YarpCluster_From_Named_Endpoint_Uses_DnsSd_Format()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+ var resource = builder.AddResource(new TestResource("ServiceC"))
+ .WithEndpoint(name: "prometheus", scheme: "http")
+ .WithEndpoint(name: "management", scheme: "https");
+
+ var prometheusCluster = new YarpCluster(resource.GetEndpoint("prometheus"));
+ Assert.Equal("http://_prometheus.ServiceC", prometheusCluster.Targets[0]);
+
+ var managementCluster = new YarpCluster(resource.GetEndpoint("management"));
+ Assert.Equal("https://_management.ServiceC", managementCluster.Targets[0]);
}
[Fact]
diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs
index ffc9fa49d2a..f10eea3a160 100644
--- a/tests/Shared/Hex1bAutomatorTestHelpers.cs
+++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs
@@ -1,7 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics;
using Hex1b.Automation;
+using Hex1b.Input;
namespace Aspire.Tests.Shared;
@@ -59,6 +61,186 @@ await auto.WaitUntilAsync(snapshot =>
counter.Increment();
}
+ ///
+ /// Repeatedly types a shell command until the first line of command output matches the expected text
+ /// or the timeout expires. Each attempt waits for either the first output line or the next prompt,
+ /// then waits for the prompt if output appeared first.
+ ///
+ internal static async Task ExecuteCommandUntilOutputAsync(
+ this Hex1bTerminalAutomator auto,
+ SequenceCounter counter,
+ string commandText,
+ string desiredOutput,
+ TimeSpan? timeout = null,
+ TimeSpan? retryInterval = null)
+ {
+ ArgumentNullException.ThrowIfNull(auto);
+ ArgumentNullException.ThrowIfNull(counter);
+ ArgumentException.ThrowIfNullOrWhiteSpace(commandText);
+ ArgumentException.ThrowIfNullOrWhiteSpace(desiredOutput);
+
+ var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30);
+ var effectiveRetryInterval = retryInterval ?? TimeSpan.FromSeconds(5);
+ var stopwatch = Stopwatch.StartNew();
+ var attempt = 0;
+
+ while (stopwatch.Elapsed < effectiveTimeout)
+ {
+ attempt++;
+ var expectedPromptSequence = counter.Value;
+ var sawPrompt = false;
+ var firstOutputMatched = false;
+
+ await auto.TypeAsync(commandText);
+ await auto.EnterAsync();
+
+ var remaining = effectiveTimeout - stopwatch.Elapsed;
+ if (remaining <= TimeSpan.Zero)
+ {
+ break;
+ }
+
+ var waitThisAttempt = remaining < effectiveRetryInterval ? remaining : effectiveRetryInterval;
+
+ try
+ {
+ await auto.WaitUntilAsync(snapshot =>
+ {
+ var firstOutputLine = TryGetFirstOutputLine(snapshot, commandText);
+ if (firstOutputLine is not null)
+ {
+ if (IsPromptLine(firstOutputLine, expectedPromptSequence))
+ {
+ sawPrompt = true;
+ return true;
+ }
+
+ firstOutputMatched = firstOutputLine.Contains(desiredOutput, StringComparison.Ordinal);
+ return true;
+ }
+
+ if (IsPromptVisible(snapshot, expectedPromptSequence))
+ {
+ sawPrompt = true;
+ return true;
+ }
+
+ return false;
+ }, timeout: waitThisAttempt, description: $"waiting for first output or prompt after '{commandText}' (attempt {attempt})");
+ }
+ catch (TimeoutException) when (stopwatch.Elapsed < effectiveTimeout)
+ {
+ continue;
+ }
+
+ if (!sawPrompt)
+ {
+ remaining = effectiveTimeout - stopwatch.Elapsed;
+ if (remaining <= TimeSpan.Zero)
+ {
+ break;
+ }
+
+ try
+ {
+ await auto.WaitForAnyPromptAsync(counter, remaining);
+ sawPrompt = true;
+ }
+ catch (TimeoutException) when (stopwatch.Elapsed < effectiveTimeout)
+ {
+ continue;
+ }
+ }
+ else
+ {
+ counter.Increment();
+ }
+
+ if (firstOutputMatched)
+ {
+ return; // success
+ }
+
+ remaining = effectiveTimeout - stopwatch.Elapsed;
+ if (remaining <= TimeSpan.Zero)
+ {
+ break;
+ }
+
+ var delayBeforeRetry = remaining < effectiveRetryInterval ? remaining : effectiveRetryInterval;
+ if (delayBeforeRetry > TimeSpan.FromMilliseconds(1))
+ {
+ await auto.WaitAsync(delayBeforeRetry);
+ }
+ }
+
+ throw new TimeoutException(
+ $"Timed out after {effectiveTimeout} waiting for the first output line from '{commandText}' to contain '{desiredOutput}'.");
+ }
+
+ private static bool IsPromptVisible(IHex1bTerminalRegion snapshot, int expectedPromptSequence)
+ {
+ var successSearcher = new CellPatternSearcher()
+ .FindPattern(expectedPromptSequence.ToString())
+ .RightText(" OK] $ ");
+ var errorSearcher = new CellPatternSearcher()
+ .FindPattern(expectedPromptSequence.ToString())
+ .RightText(" ERR:");
+
+ return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0;
+ }
+
+ private static bool IsPromptLine(string line, int expectedPromptSequence)
+ {
+ return line.Contains($"[{expectedPromptSequence} OK] $", StringComparison.Ordinal) ||
+ line.Contains($"[{expectedPromptSequence} ERR:", StringComparison.Ordinal);
+ }
+
+ private static string? TryGetFirstOutputLine(IHex1bTerminalRegion snapshot, string commandText)
+ {
+ var commandLineIndex = FindCommandLineIndex(snapshot, commandText);
+ if (commandLineIndex < 0)
+ {
+ return null;
+ }
+
+ for (var lineIndex = commandLineIndex + 1; lineIndex < snapshot.Height; lineIndex++)
+ {
+ var line = snapshot.GetLineTrimmed(lineIndex);
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ return line;
+ }
+ }
+
+ return null;
+ }
+
+ private static int FindCommandLineIndex(IHex1bTerminalRegion snapshot, string commandText)
+ {
+ for (var lineIndex = snapshot.Height - 1; lineIndex >= 0; lineIndex--)
+ {
+ var line = snapshot.GetLineTrimmed(lineIndex);
+ if (line.EndsWith(commandText, StringComparison.Ordinal) ||
+ line.Contains($"$ {commandText}", StringComparison.Ordinal) ||
+ line.Contains($"# {commandText}", StringComparison.Ordinal))
+ {
+ return lineIndex;
+ }
+ }
+
+ for (var lineIndex = snapshot.Height - 1; lineIndex >= 0; lineIndex--)
+ {
+ var line = snapshot.GetLineTrimmed(lineIndex);
+ if (line.Contains(commandText, StringComparison.Ordinal))
+ {
+ return lineIndex;
+ }
+ }
+
+ return -1;
+ }
+
///
/// Waits for a successful command prompt, but fails fast if an error prompt is detected.
///
diff --git a/tests/Shared/RepoTesting/Aspire.RepoTesting.targets b/tests/Shared/RepoTesting/Aspire.RepoTesting.targets
index 2f07ae0e92f..35adf7647d5 100644
--- a/tests/Shared/RepoTesting/Aspire.RepoTesting.targets
+++ b/tests/Shared/RepoTesting/Aspire.RepoTesting.targets
@@ -33,7 +33,7 @@
`AspireProjectOrPackageReference` - maps to projects in `src/` or `src/Components/`
-->
-
+
@@ -165,6 +165,6 @@
$(MajorVersion).$(MinorVersion).$(PatchVersion)
-
+
diff --git a/tests/Shared/RepoTesting/Directory.Packages.Helix.props b/tests/Shared/RepoTesting/Directory.Packages.Helix.props
index 26589f7f956..d2b22fcfccf 100644
--- a/tests/Shared/RepoTesting/Directory.Packages.Helix.props
+++ b/tests/Shared/RepoTesting/Directory.Packages.Helix.props
@@ -3,82 +3,82 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs
index fd62bee78a7..16dcad85bd6 100644
--- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs
+++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs
@@ -237,6 +237,7 @@ public static TelemetryRepository CreateRepository(
int? maxAttributeLength = null,
int? maxSpanEventCount = null,
int? maxTraceCount = null,
+ int? maxLogCount = null,
TimeSpan? subscriptionMinExecuteInterval = null,
ILoggerFactory? loggerFactory = null,
PauseManager? pauseManager = null,
@@ -263,6 +264,10 @@ public static TelemetryRepository CreateRepository(
{
options.MaxTraceCount = maxTraceCount.Value;
}
+ if (maxLogCount != null)
+ {
+ options.MaxLogCount = maxLogCount.Value;
+ }
var repository = new TelemetryRepository(
loggerFactory ?? NullLoggerFactory.Instance,