
Run Device Tests
- 1 installs
- 23.3k repo stars
- Updated August 5, 2026
- dotnet/maui
Build and run .NET MAUI device tests locally on iOS, MacCatalyst, Android, or Windows with test-category filtering.
About
A device-testing skill for .NET MAUI that builds and runs tests on simulators, emulators, and desktop targets using xharness with TestFilter. A developer uses it to run specific MAUI test categories per host OS.
- Supports iOS/MacCatalyst/Android on macOS and Android/Windows on Windows
- Uses TestFilter to run specific test categories via xharness
Run Device Tests by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dotnet/maui --skill run-device-testsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 23.3k |
| Last updated | August 5, 2026 |
| Repository | dotnet/maui ↗ |
What it does
Build and run .NET MAUI device tests locally on iOS, MacCatalyst, Android, or Windows with test-category filtering.
Files
Run Device Tests Skill
Build and run .NET MAUI device tests locally on iOS simulators, MacCatalyst, Android emulators, or Windows.
Platform Support
| Host OS | Supported Platforms |
|---|---|
| macOS | ios, maccatalyst, android |
| Windows | android, windows |
Tools Required
This skill uses bash together with pwsh (PowerShell 7+) to run the PowerShell scripts. Requires:
xharness- Global dotnet tool for running tests on iOS/MacCatalyst/Androiddotnet- .NET SDK with platform workloads installed- iOS/MacCatalyst: Xcode with iOS simulators
- Android: Android SDK with emulator
- Windows: Windows SDK
Dependencies
This skill uses shared infrastructure scripts:
.github/scripts/shared/Start-Emulator.ps1- Detects and boots iOS simulators / Android emulators.github/scripts/shared/shared-utils.ps1- Common utility functions
These are automatically loaded by the Run-DeviceTests.ps1 script.
When to Use
- User wants to run device tests locally
- User wants to verify iOS/MacCatalyst/Android/Windows compatibility
- User wants to test on a specific iOS version (e.g., iOS 26)
- User asks "run device tests for Controls/Core/Essentials/Graphics"
- User asks "test on iOS simulator" or "test on Android emulator"
- User asks "run device tests on MacCatalyst"
- User wants to run only specific test categories (e.g., "run Button tests")
Available Test Projects
| Project | Path |
|---|---|
| Controls | src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj |
| Core | src/Core/tests/DeviceTests/Core.DeviceTests.csproj |
| Essentials | src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj |
| Graphics | src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj |
| BlazorWebView | src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj |
| AI | src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj |
Scripts
All scripts are in .github/skills/run-device-tests/scripts/
Run Device Tests (Full Workflow)
# Run Controls device tests on iOS simulator (default on macOS)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios
# Run Core device tests on MacCatalyst
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Core -Platform maccatalyst
# Run Controls device tests on Android emulator
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android
# Run Controls device tests on Windows (default on Windows)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform windows
# Run on specific iOS version
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Core -Platform ios -iOSVersion 26
# Run with test filter
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter "Category=Button"
# Run other test projects
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Graphics -Platform maccatalyst
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project BlazorWebView -Platform iosBuild Only (No Test Run)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -BuildOnlyList Available Simulators/Emulators
# iOS simulators
xcrun simctl list devices available
# Android emulators
emulator -list-avdsWorkflow
1. Run tests: scripts/Run-DeviceTests.ps1 -Project <name> -Platform <platform> automatically detects/boots device, builds, and runs tests 2. Check results: Look at the console output or artifacts/log/ directory for detailed test results
Output
- Build artifacts:
artifacts/bin/<Project>.DeviceTests/<Configuration>/<tfm>/<rid>/ - Test logs:
artifacts/log/ - Test results summary is printed to console
Prerequisites
xharnessglobal tool:dotnet tool install --global Microsoft.DotNet.XHarness.CLI- .NET SDK with platform workloads
- iOS/MacCatalyst: Xcode with simulators installed
- Android: Android SDK with emulator configured
- Windows: Windows SDK
- For iOS 26: macOS Tahoe (26) with Xcode 26
Examples
# Quick test run for Controls on iOS (default on macOS)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios
# Test on MacCatalyst
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform maccatalyst
# Test on Android emulator (works on both macOS and Windows)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android
# Test on Windows (default on Windows)
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform windows
# Test on iOS 26 specifically
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -iOSVersion 26
# Run only Button category tests on iOS
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter "Category=Button"
# Build for Android without running tests
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Core -Platform android -BuildOnlyNotes
- The script automatically detects and boots an iOS simulator / Android emulator using the shared Start-Emulator.ps1 infrastructure
- Default iOS simulator is iPhone Xs with iOS 18.5 (same as UI tests)
- Default Android emulator priority: API 30 Nexus > API 30 > Nexus > First available
- MacCatalyst runs directly on the Mac (no simulator needed)
- Windows tests run directly on the local machine
- Simulator/emulator selection and boot logic is handled by
.github/scripts/shared/Start-Emulator.ps1 - xharness manages test execution and reporting for iOS/MacCatalyst/Android
- Windows runs the built device-test app directly and reads its xUnit XML results, matching
eng/devices/windows.cake
Test Filtering
The -TestFilter parameter allows running specific test categories instead of all tests. This is useful for quick iteration during development.
Filter Syntax
| Format | Description | Example |
|---|---|---|
Category=X | Run only category X | Category=Button |
SkipCategories=X,Y,Z | Skip categories X, Y, Z | SkipCategories=Shell,CollectionView |
Examples
# Run only Button tests on iOS
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter "Category=Button"
# Run only Button tests on Android
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Button"
# Skip heavy test categories
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter "SkipCategories=Shell,CollectionView,HybridWebView"How Test Filtering Works
Test filtering is implemented in src/Core/tests/DeviceTests.Shared/DeviceTestSharedHelpers.cs:
| Platform | How Filter is Passed | How Filter is Read |
|---|---|---|
| iOS/MacCatalyst | --set-env=TestFilter=... | NSProcessInfo.ProcessInfo.Environment["TestFilter"] |
| Android | --arg TestFilter=... | MauiTestInstrumentation.Current.Arguments.GetString("TestFilter") |
| Windows Controls | App argument selects discovered category index | ControlsHeadlessTestRunner category loop |
Available Test Categories
Common categories in Controls.DeviceTests:
Button,Label,Entry,Editor- Individual control testsCollectionView,ListView,CarouselView- Collection controls (heavy)Shell,Navigation,TabbedPage- Navigation tests (heavy)Layout,FlexLayout- Layout testsMemory- Memory leak testsAccessibility- Accessibility testsGesture- Gesture recognition tests
To see all categories, check src/Controls/tests/DeviceTests/TestCategory.cs.
Troubleshooting
Xcode Version Mismatch
If you see errors about Xcode version mismatch (e.g., "Xcode 26.2 installed but 26.0 required"):
# Use -SkipXcodeVersionCheck to bypass validation
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -SkipXcodeVersionCheckMacCatalyst App Bundle Not Found
MacCatalyst apps use display names (e.g., "Controls Tests.app") not assembly names. The script handles this automatically by searching for .app bundles in the output directory.
Android Package Name Issues
Android package names must be lowercase (e.g., com.microsoft.maui.controls.devicetests). The script has a mapping of project names to correct package names.
Test Filter Not Working
1. Ensure full rebuild: The test filter code must be compiled into the app. Delete artifacts and rebuild:
rm -rf artifacts/bin/<Project>.DeviceTests
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Button"2. Check the console output: Look for TestFilter: Category=Button in the test output to confirm filter was applied.
3. Verify category exists: Ensure the category name matches exactly (case-sensitive).
XHarness Device Detection
The script automatically handles XHarness device targeting for iOS and Android:
iOS
1. Simulator Boot: Start-Emulator.ps1 detects and boots appropriate iOS simulator 2. UDID Extraction: Script extracts simulator UDID from Start-Emulator.ps1 output 3. iOS Version Detection: Script queries xcrun simctl to get iOS version from booted simulator 4. XHarness Targeting: Passes both --target ios-simulator-64_VERSION and --device UDID to xharness for explicit targeting
MacCatalyst
- Runs directly on the Mac, no device targeting needed
- XHarness uses
--target maccatalyst
Android
1. Emulator Boot: Start-Emulator.ps1 detects running device or boots emulator 2. Device ID Extraction: Script gets device ID (e.g., emulator-5554) 3. XHarness Targeting: Passes --device-id to xharness android command
Windows
- No device/emulator needed
- Runs the built device-test app directly and parses
TestResults-*.xml
Why both --target and --device for iOS?
- XHarness requires
--target ios-simulator-64(orios-simulator-64_VERSION) to specify platform type - Adding
--device UDIDexplicitly tells xharness which simulator to use - This combination ensures reliable device selection even on ARM64 Macs where automatic detection can fail
Example xharness invocations:
# iOS with test filter
dotnet xharness apple test \
--app path/to/app \
--target ios-simulator-64_18.5 \
--device 56AE278D-60F7-4892-9DE0-6341357CA068 \
-o artifacts/log \
--timeout 01:00:00 \
-v \
--set-env=TestFilter=Category=Button
# MacCatalyst with test filter
dotnet xharness apple test \
--app path/to/app \
--target maccatalyst \
-o artifacts/log \
--timeout 01:00:00 \
-v \
--set-env=TestFilter=Category=Button
# Android with test filter
dotnet xharness android test \
--app path/to/app.apk \
--package-name com.microsoft.maui.controls.devicetests \
--device-id emulator-5554 \
-o artifacts/log \
--timeout 01:00:00 \
-v \
--arg TestFilter=Category=ButtonThis ensures tests run on the correct device with proper version targeting and filtering.
<#
.SYNOPSIS
Builds and runs .NET MAUI device tests locally using xharness (Apple/Android) or the Windows device-test app directly.
.DESCRIPTION
This script builds a specified MAUI device test project for the target platform
and runs the tests. It handles device/emulator/simulator selection, build configuration,
and test execution.
Platform support by OS:
- macOS: ios, maccatalyst, android
- Windows: android, windows
.PARAMETER Project
The device test project to run. Valid values: Controls, Core, Essentials, Graphics, BlazorWebView, AI
.PARAMETER Platform
Target platform. Valid values depend on OS:
- macOS: ios (default), maccatalyst, android
- Windows: android, windows (default)
.PARAMETER iOSVersion
Optional iOS version to target (e.g., "26", "18"). Only applies to ios platform.
.PARAMETER Configuration
Build configuration. Defaults to "Release".
.PARAMETER TestFilter
Optional test filter to run specific tests (e.g., "Category=Button").
.PARAMETER BuildOnly
If specified, only builds the project without running tests.
.PARAMETER OutputDirectory
Directory for test logs and results. Defaults to "artifacts/log".
.PARAMETER Timeout
Test timeout in format HH:MM:SS. Defaults to "01:00:00" (1 hour).
.PARAMETER DeviceUdid
Optional specific device UDID to use. If not provided, auto-detects appropriate device.
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform ios
.EXAMPLE
./Run-DeviceTests.ps1 -Project Core -Platform maccatalyst
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform android
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform windows
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform ios -iOSVersion 26
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter "Category=Button"
.EXAMPLE
./Run-DeviceTests.ps1 -Project Controls -Platform android -BuildOnly
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[ValidateSet("Controls", "Core", "Essentials", "Graphics", "BlazorWebView", "AI")]
[string]$Project,
[Parameter(Mandatory = $false)]
[ValidateSet("ios", "maccatalyst", "android", "windows")]
[string]$Platform,
[Parameter(Mandatory = $false)]
[string]$iOSVersion,
[Parameter(Mandatory = $false)]
[string]$Configuration = "Release",
[Parameter(Mandatory = $false)]
[string]$TestFilter,
[Parameter(Mandatory = $false)]
[switch]$BuildOnly,
[Parameter(Mandatory = $false)]
[string]$OutputDirectory = "artifacts/log",
[Parameter(Mandatory = $false)]
[string]$Timeout = "01:00:00",
[Parameter(Mandatory = $false)]
[string]$DeviceUdid,
[Parameter(Mandatory = $false)]
[switch]$SkipXcodeVersionCheck
)
$ErrorActionPreference = "Stop"
# Determine default platform based on OS
if (-not $Platform) {
if ($IsWindows) {
$Platform = "windows"
} else {
$Platform = "ios"
}
}
# Validate platform availability on current OS
$validPlatforms = if ($IsWindows) { @("android", "windows") } else { @("ios", "maccatalyst", "android") }
if ($Platform -notin $validPlatforms) {
Write-Error "Platform '$Platform' is not supported on this OS. Valid platforms: $($validPlatforms -join ', ')"
exit 1
}
# iOSVersion only applies to ios platform
if ($iOSVersion -and $Platform -ne "ios") {
Write-Warning "-iOSVersion parameter is only applicable to ios platform. Ignoring."
$iOSVersion = $null
}
# Project paths mapping
$ProjectPaths = @{
"Controls" = "src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj"
"Core" = "src/Core/tests/DeviceTests/Core.DeviceTests.csproj"
"Essentials" = "src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj"
"Graphics" = "src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj"
"BlazorWebView" = "src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj"
"AI" = "src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj"
}
$AppNames = @{
"Controls" = "Microsoft.Maui.Controls.DeviceTests"
"Core" = "Microsoft.Maui.Core.DeviceTests"
"Essentials" = "Microsoft.Maui.Essentials.DeviceTests"
"Graphics" = "Microsoft.Maui.Graphics.DeviceTests"
"BlazorWebView" = "Microsoft.Maui.MauiBlazorWebView.DeviceTests"
"AI" = "Microsoft.Maui.Essentials.AI.DeviceTests"
}
$WindowsDeviceTestPackageIds = @{
"Controls" = "Microsoft.Maui.Controls.DeviceTests"
"Core" = "Microsoft.Maui.Core.DeviceTests"
"Essentials" = "Microsoft.Maui.Essentials.DeviceTests"
"Graphics" = "Microsoft.Maui.Graphics.DeviceTests"
"BlazorWebView" = "Microsoft.Maui.MauiBlazorWebView.DeviceTests"
"AI" = "Microsoft.Maui.Essentials.AI.DeviceTests"
}
function Get-CategoryFiltersFromTestFilter {
param([string]$Filter)
if ([string]::IsNullOrWhiteSpace($Filter)) {
return @()
}
$categories = @()
$matches = [regex]::Matches($Filter, '(?i)\bCategory\s*=\s*([^\|&(),]+)')
foreach ($match in $matches) {
$value = $match.Groups[1].Value.Trim().Trim('"', "'")
if (-not [string]::IsNullOrWhiteSpace($value)) {
$categories += $value
}
}
if ($categories.Count -eq 0 -and $Filter -notmatch '[=~]') {
$categories = @($Filter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
return @($categories | Select-Object -Unique)
}
function Select-WindowsDeviceTestCategories {
param(
[string[]]$AllCategories,
[string]$Filter
)
$filters = @(Get-CategoryFiltersFromTestFilter -Filter $Filter)
if ($filters.Count -eq 0) {
return @($AllCategories)
}
return @($AllCategories | Where-Object {
$category = $_
@($filters | Where-Object {
$category.Equals($_, [System.StringComparison]::OrdinalIgnoreCase) -or
$category.IndexOf($_, [System.StringComparison]::OrdinalIgnoreCase) -ge 0
}).Count -gt 0
})
}
function Wait-ForPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[int]$TimeoutSeconds,
[System.Diagnostics.Process]$Process
)
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
if (Test-Path $Path) {
return $true
}
if ($Process -and $Process.HasExited) {
Start-Sleep -Seconds 1
if (Test-Path $Path) {
return $true
}
return $false
}
Start-Sleep -Seconds 1
}
return (Test-Path $Path)
}
function Get-WindowsDeviceTestResultSummary {
param([Parameter(Mandatory = $true)][string[]]$ResultFiles)
$summary = @{
Total = 0
Passed = 0
Failed = 0
Skipped = 0
Errors = 0
}
foreach ($file in $ResultFiles) {
if (-not (Test-Path $file)) {
continue
}
[xml]$xml = Get-Content $file -Raw
$assemblies = @($xml.SelectNodes('/assemblies/assembly'))
foreach ($assembly in $assemblies) {
$summary.Total += [int]($assembly.total ?? 0)
$summary.Passed += [int]($assembly.passed ?? 0)
$summary.Failed += [int]($assembly.failed ?? 0)
$summary.Skipped += [int]($assembly.skipped ?? 0)
$summary.Errors += [int]($assembly.errors ?? 0)
}
}
return $summary
}
function Invoke-WindowsDeviceTestApp {
param(
[Parameter(Mandatory = $true)]
[string]$AppPath,
[Parameter(Mandatory = $true)]
[string]$Project,
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[string]$TestFilter,
[string]$Timeout = "01:00:00"
)
$timeoutSeconds = [int][TimeSpan]::Parse($Timeout).TotalSeconds
if ($timeoutSeconds -le 0) {
$timeoutSeconds = 3600
}
if (-not (Test-Path $OutputDirectory)) {
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
}
$packageId = $WindowsDeviceTestPackageIds[$Project]
if (-not $packageId) {
$packageId = $AppName
}
$resultBase = Join-Path $OutputDirectory "TestResults-$($packageId.Replace('.', '_'))"
$resultFile = "$resultBase.xml"
$categoriesFile = Join-Path $OutputDirectory "devicetestcategories.txt"
Remove-Item -LiteralPath $categoriesFile -Force -ErrorAction SilentlyContinue
Remove-Item -Path "$resultBase*.xml" -Force -ErrorAction SilentlyContinue
$resultFiles = @()
if ($Project -eq "Controls") {
Write-Host "Discovering Windows device test categories..." -ForegroundColor Gray
$discoveryProcess = Start-Process -FilePath $AppPath -ArgumentList @($resultFile, "-1") -PassThru
if (-not (Wait-ForPath -Path $categoriesFile -TimeoutSeconds 120 -Process $discoveryProcess)) {
if ($discoveryProcess -and -not $discoveryProcess.HasExited) {
Stop-Process -Id $discoveryProcess.Id -Force -ErrorAction SilentlyContinue
}
throw "Windows device test category discovery did not create $categoriesFile"
}
$allCategories = @(Get-Content $categoriesFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$selectedCategories = @(Select-WindowsDeviceTestCategories -AllCategories $allCategories -Filter $TestFilter)
if ($selectedCategories.Count -eq 0) {
throw "Test filter '$TestFilter' matched 0 Windows device test categories. Available categories: $($allCategories -join ', ')"
}
Write-Host "Running $($selectedCategories.Count) of $($allCategories.Count) Windows device test categor$(if ($selectedCategories.Count -eq 1) { 'y' } else { 'ies' }): $($selectedCategories -join ', ')" -ForegroundColor Yellow
foreach ($category in $selectedCategories) {
$categoryIndex = [Array]::IndexOf($allCategories, $category)
if ($categoryIndex -lt 0) {
throw "Could not find category '$category' in discovered category list."
}
$categoryResultFile = "$resultBase`_$category.xml"
Remove-Item -LiteralPath $categoryResultFile -Force -ErrorAction SilentlyContinue
Write-Host "Running Windows device test category '$category' (index $categoryIndex)..." -ForegroundColor Gray
$process = Start-Process -FilePath $AppPath -ArgumentList @($resultFile, [string]$categoryIndex) -PassThru
if (-not (Wait-ForPath -Path $categoryResultFile -TimeoutSeconds $timeoutSeconds -Process $process)) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
throw "Windows device test category '$category' did not create $categoryResultFile"
}
$resultFiles += $categoryResultFile
}
} else {
if ($TestFilter) {
Write-Warning "Windows non-Controls device tests do not support dynamic category filtering; running the full $Project device test app."
}
Write-Host "Running Windows device test app directly..." -ForegroundColor Gray
$process = Start-Process -FilePath $AppPath -ArgumentList @($resultFile) -PassThru
if (-not (Wait-ForPath -Path $resultFile -TimeoutSeconds $timeoutSeconds -Process $process)) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
throw "Windows device test app did not create $resultFile"
}
$resultFiles += $resultFile
}
$summary = Get-WindowsDeviceTestResultSummary -ResultFiles $resultFiles
$script:WindowsDeviceTestSummary = $summary
$script:WindowsDeviceTestResultFiles = $resultFiles
if (($summary.Failed + $summary.Errors) -eq 0) {
return 0
}
return 1
}
# Android package names (lowercase)
$AndroidPackageNames = @{
"Controls" = "com.microsoft.maui.controls.devicetests"
"Core" = "com.microsoft.maui.core.devicetests"
"Essentials" = "com.microsoft.maui.essentials.devicetests"
"Graphics" = "com.microsoft.maui.graphics.devicetests"
"BlazorWebView" = "com.microsoft.maui.mauiblazorwebview.devicetests"
"AI" = "com.microsoft.maui.ai.devicetests"
}
# Platform-specific configurations
$PlatformConfigs = @{
"ios" = @{
Tfm = "net10.0-ios"
RuntimeIdentifier = "iossimulator-arm64"
AppExtension = ".app"
XHarnessTarget = "ios-simulator-64"
UsesXHarness = $true
EmulatorPlatform = "ios"
}
"maccatalyst" = @{
Tfm = "net10.0-maccatalyst"
RuntimeIdentifier = "maccatalyst-arm64"
AppExtension = ".app"
XHarnessTarget = "maccatalyst"
UsesXHarness = $true
EmulatorPlatform = $null # No emulator needed for Mac Catalyst
}
"android" = @{
Tfm = "net10.0-android"
RuntimeIdentifier = $null # Let MSBuild choose
AppExtension = "-Signed.apk"
XHarnessTarget = "android-emulator-64"
UsesXHarness = $true
EmulatorPlatform = "android"
}
"windows" = @{
Tfm = "net10.0-windows10.0.19041.0"
RuntimeIdentifier = "win-x64"
AppExtension = ".exe"
XHarnessTarget = $null
UsesXHarness = $false
EmulatorPlatform = $null # No emulator needed for Windows
}
}
# Find repository root
$RepoRoot = $PSScriptRoot
while ($RepoRoot -and -not (Test-Path (Join-Path $RepoRoot ".git"))) {
$RepoRoot = Split-Path $RepoRoot -Parent
}
if (-not $RepoRoot) {
Write-Error "Could not find repository root. Run this script from within the maui repository."
exit 1
}
# Import shared utilities
$SharedScriptsDir = Join-Path $RepoRoot ".github/scripts/shared"
. (Join-Path $SharedScriptsDir "shared-utils.ps1")
Push-Location $RepoRoot
$platformConfig = $PlatformConfigs[$Platform]
try {
# Validate prerequisites
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " MAUI Device Tests Runner" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
# Check for xharness if needed (try local tool first, then global)
$useLocalXharness = $false
if ($platformConfig.UsesXHarness) {
$xharness = Get-Command "xharness" -ErrorAction SilentlyContinue
if (-not $xharness) {
# Try dotnet tool (local tool manifest)
try {
$null = & dotnet xharness help 2>&1
Write-Host "✓ xharness found: local dotnet tool" -ForegroundColor Green
$useLocalXharness = $true
} catch {
Write-Error "xharness is not installed. Install with: dotnet tool install --global Microsoft.DotNet.XHarness.CLI"
exit 1
}
} else {
Write-Host "✓ xharness found: $($xharness.Source)" -ForegroundColor Green
}
}
# Check for dotnet
$dotnet = Get-Command "dotnet" -ErrorAction SilentlyContinue
if (-not $dotnet) {
Write-Error "dotnet is not installed."
exit 1
}
Write-Host "✓ dotnet found: $($dotnet.Source)" -ForegroundColor Green
$projectPath = $ProjectPaths[$Project]
$appName = $AppNames[$Project]
# Derive artifact folder name from the project file name (e.g., "Essentials.AI.DeviceTests" from the .csproj)
$artifactName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath)
Write-Host ""
Write-Host "Project: $Project" -ForegroundColor Yellow
Write-Host "Project Path: $projectPath" -ForegroundColor Yellow
Write-Host "Platform: $Platform" -ForegroundColor Yellow
Write-Host "Configuration: $Configuration" -ForegroundColor Yellow
if ($iOSVersion) {
Write-Host "iOS Version: $iOSVersion" -ForegroundColor Yellow
}
if ($TestFilter) {
Write-Host "Test Filter: $TestFilter" -ForegroundColor Yellow
}
Write-Host ""
# ═══════════════════════════════════════════════════════════
# BUILD PHASE
# ═══════════════════════════════════════════════════════════
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Building $Project Device Tests for $Platform" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
$buildArgs = @(
"build"
$projectPath
"-c", $Configuration
"-f", $platformConfig.Tfm
"/p:TreatWarningsAsErrors=false"
)
# Add RuntimeIdentifier if specified
# NOTE: For Windows we deliberately do NOT pass `-r` here; RuntimeIdentifierOverride
# is set in the windows-specific block below to ensure the RID propagates to ALL
# referenced projects (e.g. TestUtils.DeviceTests). Plain `-r` is suppressed on
# non-leaf project references and causes PRI/asset file resolution failures.
if ($platformConfig.RuntimeIdentifier -and $Platform -ne "windows") {
$buildArgs += "-r", $platformConfig.RuntimeIdentifier
}
# Platform-specific build properties
switch ($Platform) {
"ios" {
$buildArgs += "/p:CodesignRequireProvisioningProfile=false"
if ($SkipXcodeVersionCheck) {
$buildArgs += "/p:ValidateXcodeVersion=false"
}
}
"maccatalyst" {
$buildArgs += "/p:CodesignRequireProvisioningProfile=false"
if ($SkipXcodeVersionCheck) {
$buildArgs += "/p:ValidateXcodeVersion=false"
}
}
"android" {
$buildArgs += "/p:AndroidPackageFormat=apk"
}
"windows" {
# NOTE: WindowsAppSDKSelfContained MUST NOT be passed via command line because it
# propagates to ALL referenced projects (including library dependencies like
# Graphics.csproj) and breaks them with:
# "WindowsAppSDKSelfContained requires a supported Windows architecture"
# Instead, pass _MauiDeviceTestUnpackaged=true. The
# Microsoft.Maui.TestUtils.DeviceTests.Runners.props file (imported from each
# device test csproj) converts that signal into WindowsAppSDKSelfContained=true
# ONLY on the device test project itself.
#
# Also: use RuntimeIdentifierOverride (NOT `-r`/RuntimeIdentifier) so the RID
# propagates to every ProjectReference (e.g. TestUtils.DeviceTests). Plain
# RuntimeIdentifier is auto-suppressed on non-leaf project references, which
# leaves dependency PRI/asset files in the non-RID output folder while the
# test app itself is built at the RID-specific path, producing PRI175 errors.
#
# See eng/devices/windows.cake (buildOnly task, lines 145-188) for the
# canonical CI pattern.
$buildArgs += "/p:RuntimeIdentifierOverride=$($platformConfig.RuntimeIdentifier)"
$buildArgs += "/p:WindowsPackageType=None"
$buildArgs += "/p:SelfContained=true"
$buildArgs += "/p:_MauiDeviceTestUnpackaged=true"
$buildArgs += "/p:UseMonoRuntime=false"
}
}
Write-Host "Running: dotnet $($buildArgs -join ' ')" -ForegroundColor Gray
Write-Host ""
& dotnet @buildArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
Write-Host ""
Write-Host "✓ Build succeeded" -ForegroundColor Green
# Find the built app
$tfmFolder = $platformConfig.Tfm
$ridFolder = if ($platformConfig.RuntimeIdentifier) { $platformConfig.RuntimeIdentifier } else { "" }
# Construct app path based on platform
switch ($Platform) {
"ios" {
$appPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder/$appName.app"
}
"maccatalyst" {
# MacCatalyst apps may have different names - search for .app bundle
$appSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder"
$appBundle = Get-ChildItem -Path $appSearchPath -Filter "*.app" -Directory -ErrorAction SilentlyContinue | Select-Object -First 1
if ($appBundle) {
$appPath = $appBundle.FullName
} else {
$appPath = "$appSearchPath/$appName.app"
}
}
"android" {
# Android APK path - look for signed APK
$apkSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder"
$apkFile = Get-ChildItem -Path $apkSearchPath -Filter "*-Signed.apk" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($apkFile) {
$appPath = $apkFile.FullName
} else {
# Fall back to unsigned APK
$apkFile = Get-ChildItem -Path $apkSearchPath -Filter "*.apk" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($apkFile) {
$appPath = $apkFile.FullName
} else {
$appPath = "$apkSearchPath/$appName.apk"
}
}
}
"windows" {
$exeSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder"
$exeFile = Get-ChildItem -Path $exeSearchPath -Filter "$appName.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($exeFile) {
$appPath = $exeFile.FullName
} else {
$appPath = "$exeSearchPath/$ridFolder/$appName.exe"
}
}
}
if (-not (Test-Path $appPath)) {
Write-Error "Built app not found at: $appPath"
Write-Info "Searching for app in artifacts..."
Get-ChildItem -Path "artifacts/bin/$artifactName" -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "$appName" } |
ForEach-Object { Write-Host " Found: $($_.FullName)" }
exit 1
}
Write-Host "✓ App found: $appPath" -ForegroundColor Green
if ($BuildOnly) {
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Green
Write-Host " Build completed (BuildOnly mode)" -ForegroundColor Green
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Green
exit 0
}
# ═══════════════════════════════════════════════════════════
# DEVICE/EMULATOR SETUP (if needed)
# ═══════════════════════════════════════════════════════════
$deviceUdidToUse = $DeviceUdid
$DetectedIOSVersion = $null
if ($platformConfig.EmulatorPlatform) {
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Starting $Platform Device/Emulator" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
# Use Start-Emulator.ps1 to detect/boot device
$startEmulatorPath = Join-Path $SharedScriptsDir "Start-Emulator.ps1"
$emulatorArgs = @("-File", $startEmulatorPath, "-Platform", $platformConfig.EmulatorPlatform)
if ($DeviceUdid) {
$emulatorArgs += "-DeviceUdid", $DeviceUdid
}
$emulatorOutput = & pwsh @emulatorArgs 2>&1
# Extract UDID from output (last line, trimmed)
$deviceUdidToUse = ($emulatorOutput | Select-Object -Last 1).ToString().Trim()
# Validate UDID format based on platform
$validUdid = $false
switch ($Platform) {
"ios" {
$validUdid = $deviceUdidToUse -match '^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$'
}
"android" {
$validUdid = $deviceUdidToUse -match '^emulator-\d+$' -or $deviceUdidToUse -match '^[a-zA-Z0-9]+$'
}
}
if (-not $validUdid) {
Write-Error "Failed to get valid device UDID. Got: $deviceUdidToUse"
Write-Host "Full output:" -ForegroundColor Red
$emulatorOutput | ForEach-Object { Write-Host $_ }
exit 1
}
Write-Host ""
Write-Host "✓ Device ready: $deviceUdidToUse" -ForegroundColor Green
# Extract iOS version from the booted simulator for XHarness targeting
if ($Platform -eq "ios" -and -not $iOSVersion) {
Write-Host ""
Write-Host "Detecting iOS version from simulator..." -ForegroundColor Gray
try {
$simListJson = xcrun simctl list devices available -j | ConvertFrom-Json
foreach ($runtime in $simListJson.devices.PSObject.Properties) {
$device = $runtime.Value | Where-Object { $_.udid -eq $deviceUdidToUse }
if ($device) {
# Extract version from runtime key (e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-5" -> "18.5")
if ($runtime.Name -match 'iOS-(\d+)-(\d+)') {
$DetectedIOSVersion = "$($matches[1]).$($matches[2])"
Write-Host "✓ Detected iOS version: $DetectedIOSVersion" -ForegroundColor Green
}
break
}
}
} catch {
Write-Warning "Could not detect iOS version from simulator. Continuing without version in target."
}
}
}
# ═══════════════════════════════════════════════════════════
# TEST PHASE
# ═══════════════════════════════════════════════════════════
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Running Tests" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
# Create output directory
if (-not (Test-Path $OutputDirectory)) {
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
}
$testExitCode = 0
if ($platformConfig.UsesXHarness) {
# ═══════════════════════════════════════════════════════════
# XHARNESS TEST EXECUTION (iOS, MacCatalyst, Android)
# ═══════════════════════════════════════════════════════════
# Determine target
$target = $platformConfig.XHarnessTarget
# Add iOS version to target if available
if ($Platform -eq "ios") {
$targetVersion = if ($iOSVersion) { $iOSVersion } else { $DetectedIOSVersion }
if ($targetVersion) {
$target = "ios-simulator-64_$targetVersion"
}
}
# Build xharness arguments based on platform
switch ($Platform) {
"ios" {
$xharnessArgs = @(
"apple", "test"
"--app", $appPath
"--target", $target
"--device", $deviceUdidToUse
"-o", $OutputDirectory
"--timeout", $Timeout
"-v"
)
}
"maccatalyst" {
$xharnessArgs = @(
"apple", "test"
"--app", $appPath
"--target", "maccatalyst"
"-o", $OutputDirectory
"--timeout", $Timeout
"-v"
)
}
"android" {
$androidPackageName = $AndroidPackageNames[$Project]
$xharnessArgs = @(
"android", "test"
"--app", $appPath
"--package-name", $androidPackageName
"--device-id", $deviceUdidToUse
"-o", $OutputDirectory
"--timeout", $Timeout
"-v"
)
}
}
if ($TestFilter) {
if ($Platform -eq "android") {
# Android uses --arg for instrumentation arguments
$xharnessArgs += "--arg", "TestFilter=$TestFilter"
} else {
# iOS/MacCatalyst uses --set-env
$xharnessArgs += "--set-env=TestFilter=$TestFilter"
}
}
if ($useLocalXharness) {
$xharnessCommand = "dotnet xharness"
} else {
$xharnessCommand = "xharness"
}
Write-Host "Running: $xharnessCommand $($xharnessArgs -join ' ')" -ForegroundColor Gray
Write-Host ""
Write-Host "Target: $target" -ForegroundColor Yellow
if ($deviceUdidToUse) {
Write-Host "Device: $deviceUdidToUse" -ForegroundColor Yellow
}
Write-Host ""
if ($useLocalXharness) {
& dotnet xharness @xharnessArgs
} else {
& xharness @xharnessArgs
}
$testExitCode = $LASTEXITCODE
} else {
# ═══════════════════════════════════════════════════════════
# WINDOWS DEVICE TEST EXECUTION
# ═══════════════════════════════════════════════════════════
Write-Host "Running Windows device test app directly..." -ForegroundColor Gray
Write-Host "This matches eng/devices/windows.cake and avoids VSTest/testhost for MAUI Windows device apps." -ForegroundColor Gray
Write-Host ""
$testExitCode = Invoke-WindowsDeviceTestApp `
-AppPath $appPath `
-Project $Project `
-AppName $appName `
-OutputDirectory $OutputDirectory `
-TestFilter $TestFilter `
-Timeout $Timeout
if ($script:WindowsDeviceTestSummary) {
Write-Host ""
Write-Output " Passed: $($script:WindowsDeviceTestSummary.Passed)"
Write-Output " Failed: $($script:WindowsDeviceTestSummary.Failed + $script:WindowsDeviceTestSummary.Errors)"
Write-Output " Skipped: $($script:WindowsDeviceTestSummary.Skipped)"
Write-Output " Total: $($script:WindowsDeviceTestSummary.Total)"
Write-Host " Result file(s): $($script:WindowsDeviceTestResultFiles -join ', ')" -ForegroundColor Gray
}
}
# ═══════════════════════════════════════════════════════════
# RESULTS
# ═══════════════════════════════════════════════════════════
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Test Results" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan
# Try to find and parse the log file
$logFile = Get-ChildItem -Path $OutputDirectory -Filter "$appName.log" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($logFile) {
$logContent = Get-Content $logFile.FullName -Raw
$passCount = ([regex]::Matches($logContent, '\[PASS\]')).Count
$failCount = ([regex]::Matches($logContent, '\[FAIL\]')).Count
# Use Write-Output for results so they're captured by callers (not just Write-Host)
Write-Host ""
Write-Output " Passed: $passCount"
Write-Output " Failed: $failCount"
Write-Host ""
Write-Host " Log file: $($logFile.FullName)" -ForegroundColor Gray
if ($failCount -gt 0) {
Write-Host ""
Write-Host " Failed tests:" -ForegroundColor Red
$logContent -split "`n" | Where-Object { $_ -match '\[FAIL\]' } |
ForEach-Object { $_ -replace '.*\[FAIL\]\s*', '' } |
Select-Object -Unique |
ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
}
}
Write-Host ""
if ($testExitCode -eq 0) {
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Green
Write-Output " Tests completed successfully"
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Green
} else {
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Yellow
Write-Output " Tests completed with exit code: $testExitCode"
Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Yellow
}
exit $testExitCode
} finally {
Pop-Location
}
#!/usr/bin/env pwsh
#Requires -Modules Pester
BeforeAll {
$scriptPath = Join-Path $PSScriptRoot 'Run-DeviceTests.ps1'
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors -and $parseErrors.Count -gt 0) {
throw ($parseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
}
foreach ($functionName in @(
'Get-CategoryFiltersFromTestFilter',
'Select-WindowsDeviceTestCategories',
'Get-WindowsDeviceTestResultSummary'
)) {
$function = $ast.Find({
$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$args[0].Name -eq $functionName
}, $true)
if (-not $function) {
throw "Function '$functionName' not found"
}
Invoke-Expression $function.Extent.Text
}
}
Describe 'Windows device test category filtering' {
It 'extracts Category filters from VSTest-style expressions' {
Get-CategoryFiltersFromTestFilter -Filter 'Category=Window|Category=Button' |
Should -Be @('Window', 'Button')
}
It 'selects matching discovered categories case-insensitively' {
Select-WindowsDeviceTestCategories `
-AllCategories @('Button', 'Window', 'Shell') `
-Filter 'Category=window' |
Should -Be @('Window')
}
It 'returns all categories when no category filter is supplied' {
Select-WindowsDeviceTestCategories `
-AllCategories @('Button', 'Window') `
-Filter '' |
Should -Be @('Button', 'Window')
}
}
Describe 'Get-WindowsDeviceTestResultSummary' {
BeforeEach {
$script:testDir = Join-Path ([System.IO.Path]::GetTempPath()) "windows-device-results-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $script:testDir -Force | Out-Null
}
AfterEach {
Remove-Item -LiteralPath $script:testDir -Recurse -Force -ErrorAction SilentlyContinue
}
It 'aggregates xUnit assembly counters from Windows device-test XML files' {
$file1 = Join-Path $script:testDir 'TestResults-One.xml'
$file2 = Join-Path $script:testDir 'TestResults-Two.xml'
@'
<assemblies>
<assembly total="3" passed="2" failed="1" skipped="0" errors="0" />
</assemblies>
'@ | Set-Content $file1 -Encoding UTF8
@'
<assemblies>
<assembly total="2" passed="1" failed="0" skipped="1" errors="0" />
</assemblies>
'@ | Set-Content $file2 -Encoding UTF8
$summary = Get-WindowsDeviceTestResultSummary -ResultFiles @($file1, $file2)
$summary.Total | Should -Be 5
$summary.Passed | Should -Be 3
$summary.Failed | Should -Be 1
$summary.Skipped | Should -Be 1
$summary.Errors | Should -Be 0
}
}