Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a7e4e33
Add PR category detection for UI tests
jfversluis Dec 16, 2025
fdbc7cb
Fix default matrix handling in UI test template
jfversluis Dec 16, 2025
068e0e8
Fix: use runtime coalesce expression for category matrix
jfversluis Dec 16, 2025
d252f7c
Fix PowerShell variable interpolation syntax
jfversluis Dec 16, 2025
3a1df7b
Fix regex character class syntax in PowerShell
jfversluis Dec 16, 2025
0104024
Add dummy Button test to validate category detection
jfversluis Dec 16, 2025
3903280
Add category filtering logic to all test stages
jfversluis Dec 16, 2025
6179f0e
Fix variable scoping: move stage dependency reference to job level
jfversluis Dec 16, 2025
a3843b5
Fix: Overwrite CATEGORYGROUP variable instead of creating new one
jfversluis Dec 16, 2025
9c51137
Fix: Use EFFECTIVE_FILTER variable - CATEGORYGROUP is readonly
jfversluis Dec 16, 2025
f6e8f5f
Simplify: Move category filter logic into ui-tests-steps.yml
jfversluis Dec 16, 2025
f2ffe5f
Fix: Exit early when no matching categories instead of running tests
jfversluis Dec 16, 2025
e6e4542
Skip setup steps when UI test category doesn't match
jfversluis Dec 16, 2025
f060cc5
Add SKIP_PROVISIONING check to provision.yml
jfversluis Dec 16, 2025
07df240
Add category detection to CV2/CARV2 stages
jfversluis Dec 17, 2025
7e2ca94
Fix: Detect unexpanded $(CATEGORYGROUP) variable
jfversluis Dec 17, 2025
599710f
Test: Add dummy Label test for category detection validation
jfversluis Dec 18, 2025
d5d2dde
Fix: Add explicit Category attribute to dummy Label test
jfversluis Dec 18, 2025
f9ec697
Remove dummy Button and Label tests
jfversluis Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions eng/pipelines/common/provision.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ parameters:

steps:

##################################################
# Skip Check for UI Test Optimization #
##################################################

# If SHOULD_RUN_TESTS is explicitly False (set by ui-tests-steps.yml),
# skip all provisioning to save time
- pwsh: |
$shouldRun = $env:SHOULD_RUN_TESTS
Write-Host "SHOULD_RUN_TESTS: '$shouldRun'"
if ($shouldRun -eq 'False') {
Write-Host "##[warning]Skipping all provisioning - no tests will run for this category"
Write-Host "##vso[task.setvariable variable=SKIP_PROVISIONING]true"
} else {
Write-Host "##vso[task.setvariable variable=SKIP_PROVISIONING]false"
}
displayName: 'Check if provisioning should be skipped'
env:
SHOULD_RUN_TESTS: $(SHOULD_RUN_TESTS)

##################################################
# Provisioning Feeds #
##################################################
Expand Down Expand Up @@ -77,7 +96,7 @@ steps:
- ${{ if ne(parameters.skipXcode, 'true') }}:
- ${{ if ne(parameters.skipProvisionator, true) }}:
- task: xamops.azdevex.provisionator-task.provisionator@3
condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin'))
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'), eq(variables['Agent.OS'], 'Darwin'))
displayName: 'Provision Xcode'
inputs:
provisionator_uri: ${{ parameters.provisionatorUri }}
Expand Down Expand Up @@ -140,7 +159,7 @@ steps:

sudo xcodebuild -runFirstLaunch
displayName: Select Xcode Version
condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin'))
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'), eq(variables['Agent.OS'], 'Darwin'))
timeoutInMinutes: 30

- ${{ if ne(parameters.skipSimulatorSetup, 'true') }}:
Expand Down Expand Up @@ -180,7 +199,7 @@ steps:
sudo xcodebuild -downloadPlatform iOS
fi
displayName: Install Simulator Runtimes
condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin'))
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'), eq(variables['Agent.OS'], 'Darwin'))
timeoutInMinutes: 30

# Provision Additional Software
Expand Down Expand Up @@ -224,6 +243,7 @@ steps:
# Provisioning .NET - .NET SDK
- task: UseDotNet@2
displayName: 'Use .NET SDK $(DOTNET_VERSION)'
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
inputs:
packageType: sdk
version: $(DOTNET_VERSION)
Expand All @@ -233,18 +253,21 @@ steps:
dotnet --list-sdks
dotnet --version
displayName: 'List all .NET SDKs'
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
continueOnError: true

# Provisioning .NET - Clear all NuGet caches
- ${{ if eq(parameters.clearCaches, 'true') }}:
- script: dotnet nuget locals all --clear
displayName: 'Clear all NuGet caches'
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
continueOnError: true

# Provisioning .NET - .NET tools
- script: |
dotnet tool restore --verbosity diag
displayName: 'Restore .NET Tools'
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))

##################################################
# Provisioning Android #
Expand All @@ -254,13 +277,13 @@ steps:
- ${{ if ne(parameters.skipJdk, 'true') }}:
- pwsh: dotnet build -t:ProvisionJdk -bl:"$(LogDirectory)/provision-jdk.binlog" ${{ parameters.checkoutDirectory }}/src/Provisioning/Provisioning.csproj -v:detailed
displayName: 'Provision JDK'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))

# Provisioning Android - Android SDK common packages (eg: cmdline-tools, emulator, platform-tools, build-tools)
- ${{ if ne(parameters.skipAndroidCommonSdks, 'true') }}:
- pwsh: dotnet build -t:ProvisionAndroidSdkCommonPackages -bl:"$(LogDirectory)/provision-androidsdk-common.binlog" ${{ parameters.checkoutDirectory }}/src/Provisioning/Provisioning.csproj -v:detailed
displayName: 'Provision Android SDK - Common Packages'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))

# Provisioning Android - Android environment variables
- ${{ if ne(parameters.skipAndroidCommonSdks, 'true') }}:
Expand All @@ -273,28 +296,28 @@ steps:
echo "##vso[task.setvariable variable=ANDROID_HOME]$preferredSdk"
echo "ANDROID_HOME set to '$preferredSdk'"
displayName: 'Provision Android SDK - Environment variables'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))

# Provisioning Android - Android SDK platform APIs (eg: platforms;android-29, platforms;android-30)
- ${{ if ne(parameters.skipAndroidPlatformApis, 'true') }}:
- pwsh: dotnet build -t:ProvisionAndroidSdkPlatformApiPackages -p:AndroidSdkProvisionDefaultApiLevelsOnly="$Env:AndroidSdkProvisionDefaultApiLevelsOnly" -bl:"$(LogDirectory)/provision-androidsdk-platform-apis.binlog" ${{ parameters.checkoutDirectory }}/src/Provisioning/Provisioning.csproj -v:detailed
displayName: 'Provision Android SDK - Platform APIs'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
env:
AndroidSdkProvisionDefaultApiLevelsOnly: ${{ parameters.onlyAndroidPlatformDefaultApis }}

# Provisioning Android - Emulator images (eg: system-images;android-34;google_apis;aarch64)
- ${{ if ne(parameters.skipAndroidEmulatorImages, 'true') }}:
- pwsh: dotnet build -t:ProvisionAndroidSdkEmulatorImagePackages -p:AndroidSdkProvisionApiLevel="$Env:AndroidSdkProvisionApiLevel" -bl:"$(LogDirectory)/provision-androidsdk-emulator-images.binlog" ${{ parameters.checkoutDirectory }}/src/Provisioning/Provisioning.csproj -v:detailed
displayName: 'Provision Android SDK - Emulator Images'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
env:
AndroidSdkProvisionApiLevel: ${{ parameters.androidEmulatorApiLevel }}

# Provisioning Android - Android AVDs (actual emulator virtual devices)
- ${{ if ne(parameters.skipAndroidCreateAvds, 'true') }}:
- pwsh: dotnet build -t:ProvisionAndroidSdkAvdCreateAvds -p:AndroidSdkProvisionApiLevel="$Env:AndroidSdkProvisionApiLevel" -bl:"$(LogDirectory)/provision-androidsdk-create-avds.binlog" ${{ parameters.checkoutDirectory }}/src/Provisioning/Provisioning.csproj -v:detailed
displayName: 'Provision Android SDK - Create AVDs'
condition: succeeded()
condition: and(succeeded(), ne(variables['SKIP_PROVISIONING'], 'true'))
env:
AndroidSdkProvisionApiLevel: ${{ parameters.androidEmulatorApiLevel }}
131 changes: 119 additions & 12 deletions eng/pipelines/common/ui-tests-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,82 @@ parameters:
checkoutDirectory: $(System.DefaultWorkingDirectory)

steps:
# EARLY CHECK: Determine if this category group should run tests
# This runs FIRST to avoid wasting time on provisioning if no tests will run
- pwsh: |
$testFilterParam = $env:CATEGORY_GROUP
$testFilterFallback = $env:TEST_FILTER_PARAM
$detectedCategories = $env:DETECTED_CATEGORIES
$isPR = $env:BUILD_REASON -eq "PullRequest"

Write-Host "=== Early Category Check ==="
Write-Host "Build Reason: $env:BUILD_REASON"
Write-Host "Category Group (from matrix): '$testFilterParam'"
Write-Host "Test Filter (from parameter): '$testFilterFallback'"
Write-Host "Detected Categories: '$detectedCategories'"

# Use testFilter parameter as fallback when CATEGORYGROUP is not set or not expanded
# When CATEGORYGROUP variable doesn't exist, it shows as literal '$(CATEGORYGROUP)'
$categoryGroupNotSet = [string]::IsNullOrWhiteSpace($testFilterParam) -or $testFilterParam -eq '$(CATEGORYGROUP)'
if ($categoryGroupNotSet -and -not [string]::IsNullOrWhiteSpace($testFilterFallback)) {
$testFilterParam = $testFilterFallback
Write-Host "Using testFilter parameter as category: '$testFilterParam'"
}

$shouldRun = $true

# For PRs with detected categories, check if this category group has any matches
if ($isPR -and -not [string]::IsNullOrWhiteSpace($detectedCategories) -and -not [string]::IsNullOrWhiteSpace($testFilterParam)) {
$categoryList = $testFilterParam -split ","
$detectedList = $detectedCategories -split ","
$hasMatch = $false

foreach ($cat in $categoryList) {
$cat = $cat.Trim()
foreach ($det in $detectedList) {
$det = $det.Trim()
if ($cat -eq $det) {
$hasMatch = $true
Write-Host "Match found: '$cat'"
break
}
}
if ($hasMatch) { break }
}
Comment on lines +48 to +59
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The category comparison at lines 52 and 224 uses case-sensitive string equality ($cat -eq $det). However, the HashSet used for collecting categories in the PowerShell detection script uses case-insensitive comparison (line 57: [System.StringComparer]::OrdinalIgnoreCase). This inconsistency could cause matching failures if category names differ in case between the detection script output and the matrix CATEGORYGROUP values. Use case-insensitive comparison here as well with -ieq or .ToLower() for consistency.

Copilot uses AI. Check for mistakes.

if (-not $hasMatch) {
$shouldRun = $false
Write-Host "##[warning]No matching categories - SKIPPING all steps for this job"
Write-Host "Category group '$testFilterParam' does not contain any detected categories: $detectedCategories"
}
}

Write-Host "Should run tests: $shouldRun"
Write-Host "##vso[task.setvariable variable=SHOULD_RUN_TESTS]$shouldRun"
Comment on lines +20 to +69
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early check step could skip the job entirely if SHOULD_RUN_TESTS is False, but instead it sets a variable and forces all subsequent steps to check this condition. Azure Pipelines supports job-level conditions that could terminate the job earlier. Consider using a job-level condition expression or setting the job result to "Skipped" when no matching categories are found, rather than requiring every subsequent step to check SHOULD_RUN_TESTS.

Copilot uses AI. Check for mistakes.
displayName: 'Check if category should run'
env:
CATEGORY_GROUP: $(CATEGORYGROUP)
TEST_FILTER_PARAM: ${{ parameters.testFilter }}
DETECTED_CATEGORIES: $(DETECTED_CATEGORIES)
BUILD_REASON: $(Build.Reason)

- task: DownloadPipelineArtifact@2
condition: and(ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'Mono'))
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'Mono'))
inputs:
artifact: ui-tests-samples

- task: DownloadPipelineArtifact@2
condition: and(ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'NativeAOT'))
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'NativeAOT'))
inputs:
artifact: ui-tests-samples-nativeaot

- task: DownloadPipelineArtifact@2
condition: and(ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'CoreCLR'))
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'CoreCLR'))
inputs:
artifact: ui-tests-samples-coreclr

- task: DownloadPipelineArtifact@2
condition: eq('${{ parameters.platform }}' , 'windows')
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), eq('${{ parameters.platform }}' , 'windows'))
inputs:
artifact: ui-tests-samples-windows

Expand All @@ -40,6 +99,7 @@ steps:
chmod +x $(System.DefaultWorkingDirectory)/eng/scripts/clean-bot.sh
$(System.DefaultWorkingDirectory)/eng/scripts/clean-bot.sh
displayName: 'Clean bot'
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
continueOnError: true
timeoutInMinutes: 60

Expand All @@ -50,13 +110,14 @@ steps:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
displayName: Enable KVM
condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), eq(variables['Agent.OS'], 'Linux'))

- ${{ if eq(parameters.platform, 'catalyst')}}:
- bash: |
chmod +x $(System.DefaultWorkingDirectory)/eng/scripts/disable-notification-center.sh
$(System.DefaultWorkingDirectory)/eng/scripts/disable-notification-center.sh
displayName: 'Disable Notification Center'
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
continueOnError: true
timeoutInMinutes: 60

Expand All @@ -80,7 +141,7 @@ steps:
openSslArgs: '' # Do not use legacy openssl for Catalyst builds

- task: PowerShell@2
condition: and(ne('${{ parameters.platform }}', 'windows'), ne('${{ parameters.platform }}', 'android'))
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), ne('${{ parameters.platform }}', 'windows'), ne('${{ parameters.platform }}', 'android'))
inputs:
targetType: 'inline'
script: |
Expand All @@ -92,22 +153,25 @@ steps:

- pwsh: ./build.ps1 --target=dotnet --configuration="${{ parameters.configuration }}" --verbosity=diagnostic
displayName: 'Install .NET'
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
retryCountOnTaskFailure: 2
env:
DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token)
PRIVATE_BUILD: $(PrivateBuild)

- pwsh: echo "##vso[task.prependpath]$(DotNet.Dir)"
displayName: 'Add .NET to PATH'
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))

# AzDO hosted agents default to 1024x768; set something bigger for Windows UI tests
- pwsh: |
$scriptPath = Join-Path "$(System.DefaultWorkingDirectory)" "eng" "scripts" "Set-ScreenResolution.ps1"
& $scriptPath -Width 1920 -Height 1080
condition: eq('${{ parameters.platform }}' , 'windows')
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'), eq('${{ parameters.platform }}' , 'windows'))
displayName: "Set screen resolution"

- task: UseNode@1
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
inputs:
version: "20.3.1"
displayName: "Install node"
Expand All @@ -116,6 +180,7 @@ steps:
$skipAppiumDoctor = if ($IsMacOS -or $IsLinux) { "true" } else { "false" }
dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="$skipAppiumDoctor" -bl:"$(LogDirectory)/provision-appium.binlog"
displayName: "Install Appium"
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
continueOnError: false
retryCountOnTaskFailure: 2
timeoutInMinutes: 10
Expand All @@ -131,12 +196,50 @@ steps:

$testFilter = ""
$testConfigrationArgs = "${{ parameters.testConfigurationArgs }}"

"${{ parameters.testFilter }}".Split(",") | ForEach {
$testFilter += "TestCategory=" + $_ + "|"

# Get test filter from environment variable (passed from matrix via env block)
$testFilterParam = $env:CATEGORY_GROUP
$detectedCategories = $env:DETECTED_CATEGORIES
$isPR = $env:BUILD_REASON -eq "PullRequest"

Write-Host "Category Group from matrix: '$testFilterParam'"
Write-Host "Detected Categories: '$detectedCategories'"

# Fallback to parameter for non-matrix builds
if (-not $testFilterParam) {
$testFilterParam = "${{ parameters.testFilter }}"
Write-Host "Using testFilter parameter: '$testFilterParam'"
}

# For PRs with detected categories, use only matching categories
if ($isPR -and -not [string]::IsNullOrWhiteSpace($detectedCategories) -and -not [string]::IsNullOrWhiteSpace($testFilterParam)) {
$categoryList = $testFilterParam -split ","
$detectedList = $detectedCategories -split ","
$matchingCategories = @()

foreach ($cat in $categoryList) {
$cat = $cat.Trim()
foreach ($det in $detectedList) {
$det = $det.Trim()
if ($cat -eq $det) {
$matchingCategories += $cat
break
}
}
Comment on lines +220 to +228
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The category comparison uses case-sensitive string equality ($cat -eq $det). However, the HashSet in the PowerShell detection script (detect-ui-test-categories.ps1 line 57) uses case-insensitive comparison ([System.StringComparer]::OrdinalIgnoreCase). This inconsistency could cause matching failures if category names differ in case. Use case-insensitive comparison here as well with -ieq or .ToLower() for consistency.

Copilot uses AI. Check for mistakes.
}

$testFilterParam = $matchingCategories -join ","
Write-Host "Running matching categories: $testFilterParam"
}

Write-Host "Final test filter: '$testFilterParam'"

if ($testFilterParam) {
$testFilterParam.Split(",") | ForEach {
$testFilter += "TestCategory=" + $_ + "|"
}
$testFilter = $testFilter.TrimEnd("|")
}

$testFilter = $testFilter.TrimEnd("|")

Comment on lines 196 to 243
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is duplicated category matching logic between the early check step (lines 20-69) and the test filter calculation step (lines 200-233). Both steps perform the same category matching operation with identical logic. Consider extracting this into a shared function or removing one of the checks. Since the early check already determines whether tests should run and sets SHOULD_RUN_TESTS, the test filter calculation step could be simplified to only handle the filter string construction when SHOULD_RUN_TESTS is True.

Copilot uses AI. Check for mistakes.
# Cake does not allow empty parameters, so check if our filter is empty before adding it
if ($testConfigrationArgs) {
Expand All @@ -155,10 +258,14 @@ steps:

Invoke-Expression $command
displayName: $(Agent.JobName)
condition: and(succeeded(), eq(variables['SHOULD_RUN_TESTS'], 'True'))
${{ if ne(parameters.platform, 'android')}}:
retryCountOnTaskFailure: 1
env:
APPIUM_HOME: $(APPIUM_HOME)
CATEGORY_GROUP: $(CATEGORYGROUP)
DETECTED_CATEGORIES: $(DETECTED_CATEGORIES)
BUILD_REASON: $(Build.Reason)

- bash: |
cat ${BASH_SOURCE[0]}
Expand Down
Loading
Loading