diff --git a/.github/README-AI.md b/.github/README-AI.md index c703358dfade..843637a2988c 100644 --- a/.github/README-AI.md +++ b/.github/README-AI.md @@ -250,7 +250,7 @@ Reusable skills in `.github/skills/` that agents can invoke: - **`verify-tests-fail-without-fix/`** - Verifies UI tests catch bugs (auto-detects mode based on git diff) - **`write-ui-tests/`** - Creates UI tests for issues following MAUI conventions - **`write-xaml-tests/`** - Creates XAML unit tests for parsing, XamlC, and source generation issues -- **`pr-build-status/`** - Retrieves Azure DevOps build status for PRs +- **`azdo-build-investigator/`** - MAUI-specific CI investigation context (works with `ci-analysis` from arcade-skills plugin) ### Recent Improvements (January 2026) @@ -365,7 +365,7 @@ For issues or questions about the AI agent instructions: **Agent Files**: - 4 agent files (pr.md, pr/post-gate.md, sandbox-agent.md, write-tests-agent.md) -- 5 skills (try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, pr-build-status) +- 5 skills (try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, azdo-build-investigator) - All validated and consistent with consolidated structure **Automation**: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d0d99c2b8373..fb99bc2f87f5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -312,11 +312,6 @@ Skills are modular capabilities that can be invoked directly or used by agents. - **Two modes**: Verify failure only (test creation) or full verification (test + fix) - **Used by**: After creating tests, before considering PR complete -8. **pr-build-status** (`.github/skills/pr-build-status/SKILL.md`) - - **Purpose**: Retrieves Azure DevOps build information for PRs (build IDs, stage status, failed jobs) - - **Trigger phrases**: "check build for PR #XXXXX", "why did PR build fail", "get build status" - - **Used by**: When investigating CI failures - 8. **run-integration-tests** (`.github/skills/run-integration-tests/SKILL.md`) - **Purpose**: Build, pack, and run .NET MAUI integration tests locally - **Trigger phrases**: "run integration tests", "test templates locally", "run macOSTemplates tests", "run RunOniOS tests" diff --git a/.github/copilot/settings.json b/.github/copilot/settings.json new file mode 100644 index 000000000000..35358294c16e --- /dev/null +++ b/.github/copilot/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "dotnet-arcade-skills": { + "source": { + "source": "github", + "repo": "dotnet/arcade-skills" + } + } + }, + "enabledPlugins": { + "dotnet-dnceng@dotnet-arcade-skills": true + } +} diff --git a/.github/scripts/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1 index 0702c73b015a..b349753980eb 100644 --- a/.github/scripts/BuildAndRunHostApp.ps1 +++ b/.github/scripts/BuildAndRunHostApp.ps1 @@ -232,6 +232,28 @@ if ($Category) { if ($Platform -eq "android") { Write-Info "Clearing Android logcat buffer before test..." & adb -s $DeviceUdid logcat -c + + # Dismiss any ANR dialogs that may have appeared during build/deploy. + # The emulator can sit idle during long builds, causing SystemUI ANR. + Write-Info "Dismissing any system dialogs before test..." + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_ENTER 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + & adb -s $DeviceUdid shell input keyevent KEYCODE_WAKEUP 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_MENU 2>$null + Start-Sleep -Seconds 1 + + # Check for lingering ANR dialogs via window dump + $windowDump = & adb -s $DeviceUdid shell dumpsys window 2>$null | Select-String "Application Not Responding|ANR" + if ($windowDump) { + Write-Warn "ANR dialog detected — force-dismissing..." + & adb -s $DeviceUdid shell input keyevent KEYCODE_HOME 2>$null + Start-Sleep -Seconds 2 + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + } } # Capture test start time for iOS logs diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index 9a83d73634b2..4b96d435f8c1 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -131,12 +131,14 @@ if (-not $ghVersion) { } Write-Host " ✅ GitHub CLI: $ghVersion" -ForegroundColor Green -# Check Copilot CLI -$copilotVersion = copilot --version 2>$null -if (-not $copilotVersion) { +# Check Copilot CLI - use Get-Command (reliable) then get version with merged streams +$copilotCmd = Get-Command copilot -ErrorAction SilentlyContinue +if (-not $copilotCmd) { Write-Error "Copilot CLI is not installed. Install with: npm install -g @github/copilot" exit 1 } +$copilotVersion = (& copilot --version 2>&1 | Out-String).Trim() +if (-not $copilotVersion) { $copilotVersion = $copilotCmd.Source } Write-Host " ✅ Copilot CLI: $copilotVersion" -ForegroundColor Green # Check PR exists diff --git a/.github/scripts/shared/Build-AndDeploy.ps1 b/.github/scripts/shared/Build-AndDeploy.ps1 index f57d5c088094..ae81e05a1ea8 100644 --- a/.github/scripts/shared/Build-AndDeploy.ps1 +++ b/.github/scripts/shared/Build-AndDeploy.ps1 @@ -85,19 +85,56 @@ if ($Platform -eq "android") { Write-Info "Build command: dotnet build $($buildArgs -join ' ')" $buildStartTime = Get-Date + $maxAttempts = 2 + $buildExitCode = 1 + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + if ($attempt -gt 1) { + Write-Warn "Retrying build/deploy (attempt $attempt of $maxAttempts)..." + + # Uninstall any MAUI test packages to clear bad state + $installedPkg = & adb shell pm list packages 2>$null | Select-String "maui" | ForEach-Object { ($_ -replace "package:", "").Trim() } + if ($installedPkg) { + foreach ($pkg in $installedPkg) { + Write-Info "Uninstalling $pkg before retry..." + & adb uninstall $pkg 2>$null + } + } + + # Restart ADB server to recover from broken pipe / transient errors + Write-Info "Restarting ADB server..." + & adb kill-server 2>$null + Start-Sleep -Seconds 2 + & adb start-server + Start-Sleep -Seconds 2 + & adb wait-for-device + Start-Sleep -Seconds 3 + } + + & dotnet build @buildArgs + $buildExitCode = $LASTEXITCODE + + if ($buildExitCode -eq 0) { + break + } + + if ($attempt -lt $maxAttempts) { + Write-Warn "Build/deploy failed (attempt $attempt). ADB0010/broken-pipe errors are transient on API 30 — will retry." + } + } - # Build and deploy in one step (Run target handles both) - & dotnet build @buildArgs - - $buildExitCode = $LASTEXITCODE $buildDuration = (Get-Date) - $buildStartTime if ($buildExitCode -ne 0) { - Write-Error "Build/deploy failed with exit code $buildExitCode" + Write-Error "Build/deploy failed after $maxAttempts attempts with exit code $buildExitCode" exit $buildExitCode } - Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + if ($attempt -gt 1) { + Write-Success "Build and deploy succeeded on attempt $attempt in $($buildDuration.TotalSeconds) seconds" + } else { + Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + } #endregion diff --git a/.github/scripts/shared/Start-Emulator.ps1 b/.github/scripts/shared/Start-Emulator.ps1 index a5d90f192530..b1d7875326ba 100644 --- a/.github/scripts/shared/Start-Emulator.ps1 +++ b/.github/scripts/shared/Start-Emulator.ps1 @@ -65,7 +65,8 @@ if ($Platform -eq "android") { # Check if DeviceUdid is an AVD name (not an emulator-XXXX format) if ($DeviceUdid -and $DeviceUdid -notmatch "^emulator-\d+$") { # DeviceUdid is likely an AVD name - check if it's in the AVD list - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks -contains + [string[]]$avdList = @(emulator -list-avds 2>$null) if ($avdList -contains $DeviceUdid) { Write-Info "DeviceUdid '$DeviceUdid' is an AVD name. Will boot this emulator..." $selectedAvd = $DeviceUdid @@ -103,7 +104,8 @@ if ($Platform -eq "android") { # Get list of available AVDs (if not already set from parameter) if (-not $selectedAvd) { - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks indexing + [string[]]$avdList = @(emulator -list-avds 2>$null) if (-not $avdList -or $avdList.Count -eq 0) { Write-Error "No Android emulators found. Please create an Android Virtual Device (AVD) using Android Studio." @@ -119,7 +121,7 @@ if ($Platform -eq "android") { # Selection priority: # 1. API 34 device (matches CI provisioning) # 2. API 30 Nexus device - # 3. Any API 30 device + # 3. Any API 30 device (matches names like "Emulator_30", "API_30_xxx", etc.) # 4. Any Nexus device # 5. First available device @@ -132,16 +134,16 @@ if ($Platform -eq "android") { # Try to find API 30 Nexus device if (-not $selectedAvd) { - $api30Nexus = $avdList | Where-Object { $_ -match "API.*30" -and $_ -match "Nexus" } | Select-Object -First 1 + $api30Nexus = $avdList | Where-Object { $_ -match "30" -and $_ -match "Nexus" } | Select-Object -First 1 if ($api30Nexus) { $selectedAvd = $api30Nexus Write-Info "Selected API 30 Nexus device: $selectedAvd" } } - # Try to find any API 30 device + # Try to find any API 30 device (match "30" anywhere in name) if (-not $selectedAvd) { - $api30Device = $avdList | Where-Object { $_ -match "API.*30" } | Select-Object -First 1 + $api30Device = $avdList | Where-Object { $_ -match "30" } | Select-Object -First 1 if ($api30Device) { $selectedAvd = $api30Device Write-Info "Selected API 30 device: $selectedAvd" diff --git a/.github/skills/azdo-build-investigator/SKILL.md b/.github/skills/azdo-build-investigator/SKILL.md new file mode 100644 index 000000000000..bd3ddc905332 --- /dev/null +++ b/.github/skills/azdo-build-investigator/SKILL.md @@ -0,0 +1,71 @@ +--- +name: azdo-build-investigator +description: "Investigate CI failures for dotnet/maui PRs — build errors, Helix test logs, and binlog analysis. Use when asked about failing checks, CI status, test failures, 'why is CI red', 'build failed', 'what's failing on PR', Helix failures, or device test failures." +metadata: + author: dotnet-maui + version: "2.0" +--- + +# dotnet/maui CI Investigation Context + +This skill provides MAUI-specific context for CI investigation. Use it together with the `ci-analysis` skill (loaded from the `dotnet-dnceng@dotnet-arcade-skills` plugin via `.github/copilot/settings.json`). + +> **First**: invoke the `ci-analysis` skill — it handles the core investigation workflow using `Get-CIStatus.ps1` and `gh` CLI (with MCP tools as optional enhancements if available). This skill provides MAUI-specific corrections and context on top of that. + +## Script Location + +The `ci-analysis` skill and its `Get-CIStatus.ps1` script are loaded automatically from the `dotnet/arcade-skills` plugin (configured in `.github/copilot/settings.json` via `enabledPlugins`). The CLI caches scripts to `~/.copilot/installed-plugins/dotnet-arcade-skills/`. No manual download is needed. + +## MAUI CI Pipelines + +> ⚠️ The `ci-analysis` skill's reference doc lists `maui-public` as the MAUI pipeline — **this is outdated**. The correct pipeline names are below. + +| Pipeline Name | Definition ID | Purpose | +|---------------|---------------|---------| +| `maui-pr` | **302** | Main build — check this first | +| `maui-pr-devicetests` | **314** | Helix device tests (iOS, Android, Windows, MacCatalyst) | +| `maui-pr-uitests` | **313** | Appium-based UI tests | + +**Organization**: `dnceng-public` / project `public` + +**Investigation priority order**: `maui-pr` → `maui-pr-devicetests` → `maui-pr-uitests` + +Most failures are in `maui-pr`. Device test failures appear in `maui-pr-devicetests`. Focus on the first failing pipeline before checking others. + +## MAUI-Specific Quirks + +### XHarness Exit-0 Blind Spot + +XHarness (used for iOS/Android device tests in `maui-pr-devicetests`) **exits with code 0 even when tests fail**. This means: +- The ADO job shows ✅ "Succeeded" +- `ci-analysis` may report no failures +- But actual test failures are hidden inside the Helix work items + +**How to detect hidden test failures**: Query the `ResultSummaryByBuild` Helix API endpoint: +``` +GET https://helix.dot.net/api/2019-06-17/jobs/{correlationId}/aggregated +``` +Look for `Failed` > 0 in the response even when the ADO build job shows green. + +When `ci-analysis` reports a `maui-pr-devicetests` build as passing but the PR has a `s/agent-gate-failed` label or the user suspects device test failures, always cross-check Helix `ResultSummaryByBuild`. + +### Container Artifact Binlogs + +MAUI build artifacts are **Container type**, not `PipelineArtifact`. This means: +- `az pipelines runs artifact download` does **not** work for binlogs +- Artifact names are like `Windows_NT_Build Windows (Debug)_Attempt1` (not `binlog`) +- Download requires a Bearer token from `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798` +- Use the ADO File Container API: `/_apis/resources/Containers/{id}?api-version=5.0-preview&$format=OctetStream` + +If available, use the `mcp-binlog-tool` MCP server to analyze downloaded `.binlog` files. This is optional — the core investigation workflow works without it via `gh` CLI and REST APIs. + +## Common MAUI Failure Patterns + +| Pattern | Where | Notes | +|---------|-------|-------| +| `error CS####` | `maui-pr` | C# compiler error — check file/line | +| `error XA####` | `maui-pr` | Android build error | +| `XamlC` | `maui-pr` | XAML compiler — usually missing type or bad binding | +| `XHarness timeout` | `maui-pr-devicetests` Helix logs | Test killed by infrastructure; may be transient | +| `No test result files found` | `maui-pr-devicetests` Helix logs | Tests never ran or app crashed on launch | +| UI test screenshot diff | `maui-pr-uitests` | Visual regression; check baseline images | diff --git a/.github/skills/pr-build-status/SKILL.md b/.github/skills/pr-build-status/SKILL.md deleted file mode 100644 index c4a542b8b9a3..000000000000 --- a/.github/skills/pr-build-status/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: pr-build-status -description: "Retrieve Azure DevOps build information for GitHub Pull Requests, including build IDs, stage status, failed jobs, and Helix console logs for any Helix-based test failures." -metadata: - author: dotnet-maui - version: "1.1" -compatibility: Requires GitHub CLI (gh) authenticated with access to dotnet/maui repository. ---- - -# PR Build Status Skill - -Retrieve Azure DevOps build information for GitHub Pull Requests, including Helix test logs. - -## Tools Required - -This skill uses `bash` together with `pwsh` (PowerShell 7+) to run the PowerShell scripts. No file editing or other tools are required. - -## When to Use - -- User asks about CI/CD status for a PR -- User asks about failed checks or builds -- User asks "what's failing on PR #XXXXX" -- User wants to see test results -- **User asks about Helix failures (device tests, integration tests, etc.)** -- **User needs to debug why tests are failing on Helix infrastructure** - -## Scripts - -All scripts are in `.github/skills/pr-build-status/scripts/` - -### 1. Get Build IDs for a PR -```bash -pwsh .github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 -PrNumber -``` - -### 2. Get Build Status -```bash -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -# For failed jobs only: -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -FailedOnly -``` - -### 3. Get Build Errors and Test Failures -```bash -# Get all errors (build errors + test failures) -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId - -# Get only build/compilation errors -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -ErrorsOnly - -# Get only test failures -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -TestsOnly -``` - -### 4. Get Helix Console Logs -```bash -# List all Helix work items and their status -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId - -# Filter by platform -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -Platform Windows - -# Show console log content for failed work items -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -ShowConsoleLog - -# Filter by work item name and show more log lines -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 -``` - -## Workflow - -### Standard Build Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. For each build, get status: `Get-BuildInfo.ps1 -BuildId YYYYY -FailedOnly` -3. For failed builds, get errors: `Get-BuildErrors.ps1 -BuildId YYYYY` - -### Helix Test Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. Find the build with Helix jobs (e.g., `maui-pr-devicetests`, `maui-integration-tests`) -3. Get Helix logs: `Get-HelixLogs.ps1 -BuildId YYYYY -ShowConsoleLog` -4. For specific platform: `Get-HelixLogs.ps1 -BuildId YYYYY -Platform Windows -ShowConsoleLog` - -## Understanding Helix Logs - -Helix is the .NET engineering infrastructure that runs tests across multiple platforms and device types. Tests that run on Helix include: -- **Device tests** - Run on real devices/emulators (iOS, Android, Windows, MacCatalyst) -- **Integration tests** - Run on various OS configurations -- **Other distributed tests** - Any test scenario that requires Helix infrastructure - -When Helix tests fail: - -1. **Build stage** - Compiles and packages the test app/harness -2. **Helix submission** - Sends the work items to Helix queues -3. **Work item execution** - Helix runs the tests on target machines/devices -4. **Console log** - Contains stdout/stderr from the test execution - -The `Get-HelixLogs.ps1` script retrieves the console logs which show: -- Test execution output -- Any crashes or errors -- Infrastructure issues (timeouts, installation failures, etc.) - -## Common Helix Failure Patterns - -| Pattern in Console Log | Meaning | -|------------------------|---------| -| "XHarness timeout" | Test took too long, killed by infrastructure | -| "No test result files found" | Tests never ran or process crashed | -| "error MT..." or "error BL..." | Build/linking error (check build logs instead) | -| Exit code non-zero | Test failures or infrastructure issues | - -## Prerequisites - -- `gh` (GitHub CLI) - authenticated -- `pwsh` (PowerShell 7+) diff --git a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 deleted file mode 100644 index 2ad34c5e521e..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 +++ /dev/null @@ -1,208 +0,0 @@ -<# -.SYNOPSIS - Retrieves build errors and test failures from an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline to find failed jobs and tasks, - then extracts build errors (MSBuild errors, compilation failures) and - test failures with their details. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER TestsOnly - If specified, only returns test results (no build errors). - -.PARAMETER ErrorsOnly - If specified, only returns build errors (no test results). - -.PARAMETER JobFilter - Optional filter to match job/task names (supports wildcards). - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -ErrorsOnly - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -TestsOnly -JobFilter "*SafeArea*" - -.OUTPUTS - Objects with Type (BuildError/TestFailure), Source, Message, and Details properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$TestsOnly, - - [Parameter(Mandatory = $false)] - [switch]$ErrorsOnly, - - [Parameter(Mandatory = $false)] - [string]$JobFilter -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -$allResults = @() - -# --- SECTION 1: Find Build Errors from Failed Tasks --- -if (-not $TestsOnly) { - $failedTasks = $timeline.records | Where-Object { - $_.type -eq "Task" -and - $_.result -eq "failed" -and - $_.log.url -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($task in $failedTasks) { - Write-Host "Analyzing failed task: $($task.name)" -ForegroundColor Red - - try { - $log = Invoke-RestMethod -Uri $task.log.url -Method Get - $lines = $log -split "`n" - - # Find MSBuild errors and ##[error] markers - $errorLines = $lines | Where-Object { - $_ -match ": error [A-Z]+\d*:" -or # MSBuild errors (CS1234, MT1234, etc.) - $_ -match ": Error :" -or # Xamarin.Shared.Sdk errors - $_ -match "##\[error\]" # Azure DevOps error markers - } - - foreach ($errorLine in $errorLines) { - # Clean up the line - $cleanLine = $errorLine -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - $cleanLine = $cleanLine -replace "##\[error\]", "" - - # Skip generic "exited with code" errors - we want the actual error - if ($cleanLine -match "exited with code") { - continue - } - - $allResults += [PSCustomObject]@{ - Type = "BuildError" - Source = $task.name - Message = $cleanLine.Trim() - Details = "" - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } - } -} - -# --- SECTION 2: Find Test Failures from Jobs --- -if (-not $ErrorsOnly) { - $jobs = $timeline.records | Where-Object { - $_.type -eq "Job" -and - $_.log.url -and - $_.state -eq "completed" -and - $_.result -eq "failed" -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($job in $jobs) { - Write-Host "Analyzing job for test failures: $($job.name)" -ForegroundColor Yellow - - try { - $logContent = Invoke-RestMethod -Uri $job.log.url -Method Get - $lines = $logContent -split "`n" - - # Find test result lines: "Failed [duration]" - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match "^\d{4}-\d{2}-\d{2}.*\s+Failed\s+(\S+)\s+\[([^\]]+)\]") { - $testName = $matches[1] - $duration = $matches[2] - - $errorMessage = "" - $stackTrace = "" - - # Look ahead for error message and stack trace - for ($j = $i + 1; $j -lt $lines.Count; $j++) { - $line = $lines[$j] - $cleanLine = $line -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - - if ($cleanLine -match "^\s*Error Message:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 10, $lines.Count); $k++) { - $msgLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($msgLine -match "^\s*Stack Trace:" -or [string]::IsNullOrWhiteSpace($msgLine)) { - break - } - $errorMessage += $msgLine.Trim() + " " - } - } - - if ($cleanLine -match "^\s*Stack Trace:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 5, $lines.Count); $k++) { - $stLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($stLine -match "at .+ in .+:line \d+") { - $stackTrace = $stLine.Trim() - break - } - } - break - } - - # Stop if we hit the next test - if ($cleanLine -match "^\s*(Passed|Failed|Skipped)\s+\S+\s+\[") { - break - } - } - - $allResults += [PSCustomObject]@{ - Type = "TestFailure" - Source = $job.name - Message = $testName - Details = if ($errorMessage) { "$errorMessage`n$stackTrace".Trim() } else { $stackTrace } - } - } - } - } - catch { - Write-Warning "Failed to fetch log for job $($job.name): $_" - } - } -} - -# Remove duplicate errors (same message from same source) -$uniqueResults = $allResults | Group-Object -Property Type, Source, Message | ForEach-Object { - $_.Group | Select-Object -First 1 -} - -# Summary -$buildErrors = ($uniqueResults | Where-Object { $_.Type -eq "BuildError" }).Count -$testFailures = ($uniqueResults | Where-Object { $_.Type -eq "TestFailure" }).Count - -Write-Host "`nSummary: $buildErrors build error(s), $testFailures test failure(s)" -ForegroundColor Cyan - -$uniqueResults diff --git a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 deleted file mode 100644 index 12fbc89a5541..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -<# -.SYNOPSIS - Retrieves detailed status information for an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline API and returns comprehensive - information about the build including all stages, their status, and - any failed or canceled jobs. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER FailedOnly - If specified, only returns failed or canceled stages and jobs. - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -FailedOnly - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -Org "dnceng-public" -Project "public" - -.OUTPUTS - Object with BuildId, Status, Result, Stages, and FailedJobs properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$FailedOnly -) - -$ErrorActionPreference = "Stop" - -# Get build info -$buildUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}?api-version=7.0" -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/$BuildId/timeline?api-version=7.0" - -try { - $build = Invoke-RestMethod -Uri $buildUrl -Method Get -ContentType "application/json" - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps API: $_" - exit 1 -} - -# Extract stages -$stages = $timeline.records | Where-Object { $_.type -eq "Stage" } | ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - State = $_.state - Result = $_.result - } -} | Sort-Object -Property { $_.State -eq "completed" }, { $_.State -eq "inProgress" } - -# Extract failed/canceled jobs -$failedJobs = $timeline.records | - Where-Object { - ($_.type -eq "Stage" -or $_.type -eq "Job") -and - ($_.result -eq "failed" -or $_.result -eq "canceled") - } | - ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - Type = $_.type - Result = $_.result - } - } | Sort-Object -Property Type, Name - -if ($FailedOnly) { - $failedJobs -} -else { - [PSCustomObject]@{ - BuildId = $BuildId - BuildNumber = $build.buildNumber - Status = $build.status - Result = $build.result - Pipeline = $build.definition.name - StartTime = $build.startTime - FinishTime = $build.finishTime - Stages = $stages - FailedJobs = $failedJobs - Link = "https://dev.azure.com/$Org/$Project/_build/results?buildId=$BuildId" - } -} diff --git a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 b/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 deleted file mode 100644 index 64a2801aabeb..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 +++ /dev/null @@ -1,247 +0,0 @@ -<# -.SYNOPSIS - Retrieves Helix console logs for failed work items from an Azure DevOps build. - -.DESCRIPTION - Parses Azure DevOps build logs to extract Helix job IDs, then queries the Helix API - to get console logs for failed work items. This is useful for debugging any test - failures that run on Helix infrastructure (device tests, integration tests, etc.). - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER Platform - Optional filter for platform (e.g., 'Windows', 'iOS', 'Android', 'MacCatalyst'). - -.PARAMETER WorkItem - Optional filter for specific work item name (supports wildcards). - -.PARAMETER ShowConsoleLog - If specified, displays the full console log content for each failed work item. - -.PARAMETER TailLines - Number of lines to show from the end of console logs. Default is 100. - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -Platform Windows -ShowConsoleLog - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 - -.OUTPUTS - Objects with JobId, WorkItem, Queue, ConsoleUrl, and optionally ConsoleLog properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [string]$Platform, - - [Parameter(Mandatory = $false)] - [string]$WorkItem, - - [Parameter(Mandatory = $false)] - [switch]$ShowConsoleLog, - - [Parameter(Mandatory = $false)] - [int]$TailLines = 100 -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -# Build platform filter pattern -$platformPattern = if ($Platform) { - switch ($Platform.ToLower()) { - "windows" { "*Windows*" } - "ios" { "*iOS*" } - "android" { "*Android*" } - "maccatalyst" { "*MacCatalyst*|*Catalyst*" } - default { "*$Platform*" } - } -} else { - "*" -} - -# Find Helix-related tasks with logs (looking for DeviceTests tasks that submit to Helix) -$helixTasks = $timeline.records | Where-Object { - $_.name -like "*DeviceTests*" -and - $_.log.url -and - ($_.result -eq "failed" -or $_.result -eq "succeeded") -and - (-not $Platform -or $_.name -like $platformPattern) -} - -if (-not $helixTasks) { - Write-Host "No Helix-related tasks found in build $BuildId" -ForegroundColor Yellow - exit 0 -} - -$allHelixJobs = @{} -$allResults = @() - -foreach ($task in $helixTasks) { - Write-Host "Scanning task: $($task.name) [$($task.result)]" -ForegroundColor $(if ($task.result -eq "failed") { "Red" } else { "Gray" }) - - try { - $logContent = Invoke-RestMethod -Uri $task.log.url -Method Get - - # Extract Helix job IDs from log (pattern: "jobs/{guid}/workitems") - $jobMatches = [regex]::Matches($logContent, "jobs/([a-f0-9-]{36})/workitems") - - foreach ($match in $jobMatches) { - $jobId = $match.Groups[1].Value - if (-not $allHelixJobs.ContainsKey($jobId)) { - $allHelixJobs[$jobId] = @{ - Task = $task.name - Result = $task.result - } - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } -} - -if ($allHelixJobs.Count -eq 0) { - Write-Host "No Helix job IDs found in build logs" -ForegroundColor Yellow - exit 0 -} - -Write-Host "`nFound $($allHelixJobs.Count) Helix job(s)" -ForegroundColor Cyan - -# Query each Helix job for work items -foreach ($jobId in $allHelixJobs.Keys) { - $jobInfo = $allHelixJobs[$jobId] - Write-Host "`n--- Helix Job: $jobId ---" -ForegroundColor Yellow - Write-Host "From task: $($jobInfo.Task)" -ForegroundColor Gray - - try { - # Get job details for queue info - $jobDetailsUrl = "https://helix.dot.net/api/jobs/${jobId}/details?api-version=2019-06-17" - $jobDetails = Invoke-RestMethod -Uri $jobDetailsUrl -Method Get - $queue = $jobDetails.QueueId - - Write-Host "Queue: $queue" -ForegroundColor Gray - - # Get work items - $workItemsUrl = "https://helix.dot.net/api/jobs/${jobId}/workitems?api-version=2019-06-17" - $workItems = Invoke-RestMethod -Uri $workItemsUrl -Method Get - - foreach ($wi in $workItems) { - # Skip the controller work item - if ($wi.Name -eq "HelixController Work Queueing") { - continue - } - - # Apply work item filter if specified - if ($WorkItem -and $wi.Name -notlike $WorkItem) { - continue - } - - $consoleUrl = "https://helix.dot.net/api/2019-06-17/jobs/${jobId}/workitems/$($wi.Name)/console" - - # Determine if this is a failure - # Check: 1) ExitCode non-zero, 2) Parent task failed - $isFailed = $false - if ($wi.ExitCode -and $wi.ExitCode -ne 0) { - $isFailed = $true - } - elseif ($jobInfo.Result -eq "failed") { - $isFailed = $true - } - - # For work items from failed tasks, always try to get console log to check for errors - $shouldFetchLog = $ShowConsoleLog -and ($isFailed -or $jobInfo.Result -eq "failed") - - $consoleLogContent = $null - if ($shouldFetchLog) { - Write-Host "`nFetching console log for: $($wi.Name)" -ForegroundColor Cyan - - try { - $consoleLog = Invoke-RestMethod -Uri $consoleUrl -Method Get - - # Check if the log indicates a failure (even if ExitCode wasn't set) - if ($consoleLog -match "exited with (code )?1\]|ERROR:|FAILED|exception|crash") { - $isFailed = $true - } - - # Get tail lines - $lines = $consoleLog -split "`n" - if ($TailLines -gt 0 -and $lines.Count -gt $TailLines) { - $consoleLogContent = ($lines | Select-Object -Last $TailLines) -join "`n" - Write-Host "... (showing last $TailLines of $($lines.Count) lines)" -ForegroundColor Gray - } - else { - $consoleLogContent = $consoleLog - } - - Write-Host $consoleLogContent - } - catch { - Write-Warning "Failed to fetch console log: $_" - } - } - - $result = [PSCustomObject]@{ - JobId = $jobId - WorkItem = $wi.Name - State = $wi.State - ExitCode = $wi.ExitCode - Queue = $queue - IsFailed = $isFailed - ConsoleUrl = $consoleUrl - ConsoleLog = $consoleLogContent - } - - $allResults += $result - - # Print summary line - $statusColor = if ($isFailed) { "Red" } else { "Green" } - $statusSymbol = if ($isFailed) { "X" } else { "√" } - Write-Host " [$statusSymbol] $($wi.Name) (Exit: $($wi.ExitCode))" -ForegroundColor $statusColor - } - } - catch { - Write-Warning "Failed to query Helix job $jobId`: $_" - } -} - -# Summary -$failedCount = ($allResults | Where-Object { $_.IsFailed }).Count -$totalCount = $allResults.Count - -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "Summary: $failedCount failed / $totalCount total work items" -ForegroundColor $(if ($failedCount -gt 0) { "Red" } else { "Green" }) -Write-Host "========================================" -ForegroundColor Cyan - -# Output results -$allResults diff --git a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 b/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 deleted file mode 100644 index 8df3966a3577..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -<# -.SYNOPSIS - Retrieves Azure DevOps build IDs associated with a GitHub PR. - -.DESCRIPTION - Queries GitHub PR checks and extracts the Azure DevOps build IDs, - pipeline names, states, and links for each unique build. - -.PARAMETER PrNumber - The GitHub Pull Request number. - -.PARAMETER Repo - The GitHub repository in 'owner/repo' format. Defaults to 'dotnet/maui'. - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 -Repo "dotnet/maui" - -.OUTPUTS - Array of objects with Pipeline, BuildId, State, and Link properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [int]$PrNumber, - - [Parameter(Mandatory = $false)] - [string]$Repo = "dotnet/maui" -) - -$ErrorActionPreference = "Stop" - -# Validate prerequisites -if (-not (Get-Command "gh" -ErrorAction SilentlyContinue)) { - Write-Error "GitHub CLI (gh) is not installed. Install from https://cli.github.com/" - exit 1 -} - -# Get PR checks from GitHub -$checksJson = gh pr checks $PrNumber --repo $Repo --json name,link,state 2>&1 - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to get PR checks: $checksJson" - exit 1 -} - -$checks = $checksJson | ConvertFrom-Json - -# Filter to Azure DevOps checks and extract build IDs -$builds = $checks | Where-Object { $_.link -match "dev\.azure\.com" } | ForEach-Object { - $buildId = if ($_.link -match "buildId=(\d+)") { $matches[1] } else { $null } - $pipeline = ($_.name -split " ")[0] - - [PSCustomObject]@{ - Pipeline = $pipeline - BuildId = $buildId - State = $_.state - Link = $_.link - } -} | Sort-Object -Property Pipeline, BuildId -Unique - -$builds diff --git a/.github/skills/pr-finalize/SKILL.md b/.github/skills/pr-finalize/SKILL.md index c1c8af7144de..d2efd2d25ebf 100644 --- a/.github/skills/pr-finalize/SKILL.md +++ b/.github/skills/pr-finalize/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-finalize -description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use pr-build-status). +description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use azdo-build-investigator and ci-analysis). --- # PR Finalize diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml index 05e4d11a2796..b8c8c4d4399f 100644 --- a/eng/pipelines/ci-copilot.yml +++ b/eng/pipelines/ci-copilot.yml @@ -26,7 +26,8 @@ parameters: - name: pool type: object default: - name: AcesShared + name: Azure Pipelines + vmImage: ubuntu-22.04 variables: - template: /eng/pipelines/common/variables.yml@self @@ -34,6 +35,10 @@ variables: value: false - name: Codeql.SkipTaskAutoInjection value: true + - name: APPIUM_HOME + value: $(System.DefaultWorkingDirectory)/.appium/ + - name: LogDirectory + value: $(Build.ArtifactStagingDirectory)/logs stages: - stage: ReviewPR @@ -42,7 +47,7 @@ stages: - job: CopilotReview displayName: 'Run Copilot PR Reviewer Agent' pool: ${{ parameters.pool }} - timeoutInMinutes: 180 + timeoutInMinutes: 360 steps: - checkout: self fetchDepth: 0 @@ -61,135 +66,24 @@ stages: echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}" displayName: 'Set Pipeline Run Title' - # Provision environment (Xcode, .NET SDK, Android SDK, etc.) + # Enable KVM for Android emulator on Linux (same as ui-tests-steps.yml / device-tests-steps.yml) + - ${{ if eq(parameters.Platform, 'android') }}: + - template: common/enable-kvm.yml + + # Provision SDKs (same parameters as ui-tests-steps.yml) - template: common/provision.yml parameters: - skipXcode: false - skipProvisionator: false - skipAndroidCommonSdks: ${{ eq(parameters.Platform, 'ios') }} - skipAndroidPlatformApis: ${{ eq(parameters.Platform, 'ios') }} - skipJdk: ${{ eq(parameters.Platform, 'ios') }} - skipSimulatorSetup: false - skipCertificates: true - # Android emulator setup (skip for iOS) - skipAndroidEmulatorImages: ${{ eq(parameters.Platform, 'ios') }} - skipAndroidCreateAvds: ${{ eq(parameters.Platform, 'ios') }} + skipXcode: ${{ eq(parameters.Platform, 'android') }} + skipProvisionator: true + skipJdk: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCommonSdks: ${{ ne(parameters.Platform, 'android') }} + skipAndroidPlatformApis: true + onlyAndroidPlatformDefaultApis: true + skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCreateAvds: true androidEmulatorApiLevel: '30' - - # Configure AVD for hardware acceleration (AcesShared ARM64 macOS agents have HVF) - # Match maui-pr Cake script: no GPU override on macOS (uses default hw GPU) - - script: | - echo "=== Configuring AVD for hardware-accelerated emulation ===" - AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini" - if [ -f "$AVD_CONFIG" ]; then - echo "Found AVD config: $AVD_CONFIG" - cat "$AVD_CONFIG" - else - echo "##vso[task.logissue type=warning]AVD config not found at: $AVD_CONFIG" - ls -la "$HOME/.android/avd/" 2>/dev/null || echo "AVD directory not found" - fi - displayName: 'Configure AVD' - condition: eq('${{ parameters.Platform }}', 'android') - - # Set up Android SDK PATH (required on self-hosted agents) - - pwsh: | - if ($env:ANDROID_SDK_ROOT) { - $platformTools = Join-Path $env:ANDROID_SDK_ROOT "platform-tools" - $emulatorPath = Join-Path $env:ANDROID_SDK_ROOT "emulator" - $cmdlineTools = Join-Path $env:ANDROID_SDK_ROOT "cmdline-tools/latest/bin" - - # Use Azure Pipelines' prependpath command to persist across steps - Write-Host "##vso[task.prependpath]$platformTools" - Write-Host "##vso[task.prependpath]$emulatorPath" - Write-Host "##vso[task.prependpath]$cmdlineTools" - - Write-Host "Added to PATH (will apply to subsequent steps):" - Write-Host " platform-tools: $platformTools" - Write-Host " emulator: $emulatorPath" - Write-Host " cmdline-tools: $cmdlineTools" - - # Verify the tools exist - if (Test-Path (Join-Path $platformTools "adb")) { - Write-Host "✅ adb found at: $platformTools/adb" - } else { - Write-Host "⚠️ adb NOT found at expected location" - } - if (Test-Path (Join-Path $emulatorPath "emulator")) { - Write-Host "✅ emulator found at: $emulatorPath/emulator" - } else { - Write-Host "⚠️ emulator NOT found at expected location" - } - } else { - Write-Host "##vso[task.logissue type=warning]ANDROID_SDK_ROOT not set, skipping PATH setup" - } - - # Auto-detect and set JAVA_HOME if not already set - if (-not $env:JAVA_HOME) { - $jvmDir = "/Library/Java/JavaVirtualMachines" - $msJdk = Get-ChildItem "$jvmDir/microsoft-*.jdk" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($msJdk) { - $javaHome = "$($msJdk.FullName)/Contents/Home" - Write-Host "##vso[task.setvariable variable=JAVA_HOME]$javaHome" - Write-Host "Set JAVA_HOME to: $javaHome" - } - } - displayName: 'Configure Android SDK PATH' - condition: eq('${{ parameters.Platform }}', 'android') - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) - - # Start Android Emulator EARLY (using Start-Emulator.ps1 script) - - pwsh: | - Write-Host "=== Starting Android Emulator ===" - Write-Host "Working directory: $(Get-Location)" - Write-Host "ANDROID_SDK_ROOT: $env:ANDROID_SDK_ROOT" - Write-Host "JAVA_HOME: $env:JAVA_HOME" - Write-Host "PATH (first 500 chars): $($env:PATH.Substring(0, [Math]::Min(500, $env:PATH.Length)))" - - # Verify adb is in path - $adbPath = Get-Command adb -ErrorAction SilentlyContinue - if ($adbPath) { - Write-Host "adb found at: $($adbPath.Source)" - } else { - Write-Host "adb NOT in PATH, checking manually..." - $manualAdb = Join-Path $env:ANDROID_SDK_ROOT "platform-tools/adb" - if (Test-Path $manualAdb) { - Write-Host "adb exists at $manualAdb but not in PATH" - } - } - - $scriptPath = ".github/scripts/shared/Start-Emulator.ps1" - Write-Host "Script path: $scriptPath" - - if (-not (Test-Path $scriptPath)) { - Write-Host "##vso[task.logissue type=error]Script not found: $scriptPath" - exit 1 - } - Write-Host "Script exists: OK" - - $ErrorActionPreference = "Continue" - try { - Write-Host "Invoking Start-Emulator.ps1 -Platform android -DeviceUdid Emulator_30..." - & "./$scriptPath" -Platform android -DeviceUdid Emulator_30 2>&1 | ForEach-Object { Write-Host $_ } - $exitCode = $LASTEXITCODE - Write-Host "Script returned exit code: $exitCode" - } catch { - Write-Host "##vso[task.logissue type=error]Exception: $_" - Write-Host $_.ScriptStackTrace - $exitCode = 1 - } - - if ($exitCode -ne 0) { - Write-Host "##vso[task.logissue type=error]Start-Emulator failed with code $exitCode" - exit $exitCode - } - - Write-Host "=== Android Emulator Started Successfully ===" - displayName: 'Start Android Emulator' - condition: eq('${{ parameters.Platform }}', 'android') - timeoutInMinutes: 35 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) + skipSimulatorSetup: ${{ eq(parameters.Platform, 'android') }} + skipCertificates: true # Install .NET and workloads via build.ps1 - pwsh: ./build.ps1 --target=dotnet --configuration="Release" --verbosity=diagnostic @@ -210,214 +104,186 @@ stages: DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token) PRIVATE_BUILD: $(PrivateBuild) - # Verify environment is ready - - script: | - echo "=== Verifying Build Environment ===" - ERRORS="" - - # Check .NET SDK - echo "Checking .NET SDK..." - if ! dotnet --version; then - ERRORS="${ERRORS}\n- .NET SDK not available" - else - echo "✓ .NET SDK: $(dotnet --version)" - fi - - # Check Android SDK (only for Android platform) - if [ "${{ parameters.Platform }}" = "android" ]; then - echo "Checking Android SDK..." - if [ -z "$ANDROID_HOME" ] && [ -z "$ANDROID_SDK_ROOT" ]; then - ERRORS="${ERRORS}\n- ANDROID_HOME/ANDROID_SDK_ROOT not set" - else - SDK_PATH="${ANDROID_HOME:-$ANDROID_SDK_ROOT}" - if [ ! -d "$SDK_PATH" ]; then - ERRORS="${ERRORS}\n- Android SDK directory not found: $SDK_PATH" - else - echo "✓ Android SDK: $SDK_PATH" - fi - fi - fi - - # Check Xcode (macOS only) - if [ "$(uname)" = "Darwin" ]; then - echo "Checking Xcode..." - if ! xcodebuild -version; then - ERRORS="${ERRORS}\n- Xcode not available" - else - echo "✓ Xcode available" - fi + # Restore .NET tools (includes xharness) + - script: dotnet tool restore + displayName: 'Restore .NET Tools' + + # Create AVD and boot Android Emulator + - ${{ if eq(parameters.Platform, 'android') }}: + # Free disk space on hosted agents (emulator needs ~7GB for userdata partition) + - script: | + echo "=== Disk space before cleanup ===" + df -h /home + echo "Removing unnecessary tools to free space..." + sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/share/chromium 2>/dev/null || true + sudo rm -rf /opt/hostedtoolcache/CodeQL /opt/hostedtoolcache/go /opt/hostedtoolcache/Python 2>/dev/null || true + sudo rm -rf /usr/share/swift 2>/dev/null || true + echo "=== Disk space after cleanup ===" + df -h /home + displayName: 'Free Disk Space for Emulator' + + - script: | + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}" + export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH" + + echo "=== Creating AVD ===" + echo "no" | avdmanager create avd -n Emulator_30 -k "system-images;android-30;google_apis_playstore;x86_64" --device "Nexus 5X" --force - # Check iOS simulators - echo "Checking iOS simulators..." - if ! xcrun simctl list devices available | grep -q "iPhone"; then - ERRORS="${ERRORS}\n- No iOS simulators available" - else - echo "✓ iOS simulators available" + # Reduce userdata partition to fit on hosted agents (~4.2GB free) + AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini" + if [ -f "$AVD_CONFIG" ]; then + sed -i 's/disk.dataPartition.size=.*/disk.dataPartition.size=2048m/' "$AVD_CONFIG" + echo "Updated disk.dataPartition.size to 2048m" fi - fi - - # Check Java/JDK (only for Android platform) - if [ "${{ parameters.Platform }}" = "android" ]; then - echo "Checking JDK..." - if ! java -version 2>&1; then - ERRORS="${ERRORS}\n- JDK not available" - else - echo "✓ JDK available" + + # Pre-authorize ADB keys (mirrors android.cake HandleVirtualDevice) + echo "=== Pre-authorizing ADB keys ===" + mkdir -p "$HOME/.android" + if [ ! -f "$HOME/.android/adbkey" ]; then + adb keygen "$HOME/.android/adbkey" 2>/dev/null || true fi - fi - - # Report errors - if [ -n "$ERRORS" ]; then - echo "" - echo "##vso[task.logissue type=error]=== Environment Verification FAILED ===" - echo -e "Missing dependencies:$ERRORS" - echo "##vso[task.logissue type=error]Build environment is not properly configured. See above for details." - exit 1 - fi - - echo "" - echo "=== Environment Verification PASSED ===" - displayName: 'Verify Build Environment' + ADB_KEY_PUB="$HOME/.android/adbkey.pub" + AVD_DIR="$HOME/.android/avd/Emulator_30.avd" + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" + echo "ADB key pre-authorized for emulator" + fi + + echo "=== Starting Emulator ===" + # Kill any stale adb server and restart + adb kill-server 2>/dev/null || true + sleep 1 + adb start-server + + # Retry loop: emulator sometimes fails to connect ADB on first launch + MAX_LAUNCH_ATTEMPTS=2 + EMULATOR_PID="" + for LAUNCH_ATTEMPT in $(seq 1 $MAX_LAUNCH_ATTEMPTS); do + echo "--- Emulator launch attempt $LAUNCH_ATTEMPT of $MAX_LAUNCH_ATTEMPTS ---" + if [ $LAUNCH_ATTEMPT -gt 1 ]; then + echo "Cleaning up before retry..." + if [ -n "$EMULATOR_PID" ] && kill -0 "$EMULATOR_PID" 2>/dev/null; then + kill "$EMULATOR_PID" 2>/dev/null || true + sleep 2 + kill -0 "$EMULATOR_PID" 2>/dev/null && kill -9 "$EMULATOR_PID" 2>/dev/null || true + fi + sleep 3 + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + fi - # Restore .NET tools (includes xharness) - - script: | - echo "Restoring .NET tools..." - dotnet tool restore - echo "Tools restored successfully" - displayName: 'Restore .NET Tools' + nohup emulator -avd Emulator_30 -gpu swiftshader_indirect -no-window -no-snapshot -no-audio -no-boot-anim -partition-size 2048 > /tmp/emulator.log 2>&1 & + EMULATOR_PID=$! + echo "Emulator PID: $EMULATOR_PID" + + echo "Waiting for emulator device (adb wait-for-device, 120s timeout)..." + timeout 120 adb wait-for-device + if [ $? -eq 0 ]; then + echo "Device detected: $(adb devices -l | grep emulator)" + break + fi - # Boot Android emulator for UI tests - - script: | - echo "=== Booting Android Emulator ===" - - # Match maui-pr Cake script: on macOS use default GPU (hardware-accelerated), - # only use swiftshader on Linux. AcesShared agents are ARM64 macOS with HVF. - # Start emulator in background with logging for debugging - EMULATOR_LOG="/tmp/emulator-boot.log" - nohup $ANDROID_SDK_ROOT/emulator/emulator -avd Emulator_30 -no-window -no-snapshot -no-audio -no-boot-anim > "$EMULATOR_LOG" 2>&1 & - EMULATOR_PID=$! - echo "Emulator started with PID: $EMULATOR_PID" - - # Give emulator a moment to start and verify process is running - sleep 3 - if ! pgrep -f 'qemu-system' > /dev/null; then - echo "##vso[task.logissue type=error]Emulator process did not start" - echo "=== Emulator Log ===" - cat "$EMULATOR_LOG" 2>/dev/null || echo "No log available" - exit 1 - fi - echo "Emulator process verified running" - - # Wait for device to appear with timeout (don't use adb wait-for-device - can hang forever) - echo "Waiting for emulator device..." - device_timeout=60 - device_waited=0 - while ! adb devices | grep -q "emulator.*device"; do - sleep 2 - device_waited=$((device_waited + 2)) - if [ $device_waited -ge $device_timeout ]; then - echo "##vso[task.logissue type=error]Emulator device did not appear in time" - echo "=== Emulator Log (last 50 lines) ===" - tail -50 "$EMULATOR_LOG" 2>/dev/null || echo "No log available" + echo "##vso[task.logissue type=warning]adb wait-for-device timed out (attempt $LAUNCH_ATTEMPT)" adb devices -l - exit 1 - fi - done - echo "Emulator device detected" - - # Wait for boot_completed - echo "Waiting for emulator to finish booting..." - timeout=600 - waited=0 - while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do - sleep 2 - waited=$((waited + 2)) - echo "Waiting for boot... ($waited/$timeout seconds)" - if [ $waited -ge $timeout ]; then - echo "##vso[task.logissue type=error]Emulator did not boot in time" - echo "=== Emulator Log (last 50 lines) ===" - tail -50 "$EMULATOR_LOG" 2>/dev/null || echo "No log available" - adb devices -l - exit 1 - fi - done - echo "Boot completed flag set" - - # Wait for package manager service to be available (critical for app installation) - echo "Waiting for package manager service..." - pm_timeout=120 - pm_waited=0 - while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do - sleep 3 - pm_waited=$((pm_waited + 3)) - echo "Waiting for package manager... ($pm_waited/$pm_timeout seconds)" - if [ $pm_waited -ge $pm_timeout ]; then - echo "##vso[task.logissue type=error]Package manager service did not start" - echo "=== Checking services ===" - adb shell service list 2>/dev/null | head -20 || echo "Cannot list services" - exit 1 - fi - done - echo "Package manager service is ready" - - echo "=== Emulator booted successfully! ===" - adb devices -l - displayName: 'Boot Android Emulator' - condition: eq('${{ parameters.Platform }}', 'android') - continueOnError: true - timeoutInMinutes: 15 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) - JAVA_HOME: $(JAVA_HOME_17_X64) + tail -30 /tmp/emulator.log - - script: | - echo "Installing Node.js 22..." - brew install node@22 - brew link --overwrite node@22 - if ! node --version; then - echo "##vso[task.logissue type=error]Failed to install Node.js" - exit 1 - fi - npm --version - echo "Node.js installed successfully" + if [ $LAUNCH_ATTEMPT -eq $MAX_LAUNCH_ATTEMPTS ]; then + echo "##vso[task.logissue type=error]Emulator failed to connect after $MAX_LAUNCH_ATTEMPTS attempts" + exit 1 + fi + done + + timeout=300 + waited=0 + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Emulator did not finish booting after ${timeout}s" + exit 1 + fi + # At 90 seconds, restart ADB server to recover from auth issues (mirrors android.cake) + if [ $waited -eq 90 ]; then + echo "Boot taking longer than expected (90/${timeout}s). Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + echo "ADB server restarted. Continuing to wait..." + fi + # Re-ensure ADB keys every 60s during boot (mirrors android.cake PrepareDevice) + if [ $((waited % 60)) -eq 0 ] && [ $waited -gt 0 ]; then + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" 2>/dev/null || true + fi + fi + done + echo "Boot completed, waiting for package manager..." + + timeout=120 + waited=0 + while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Package manager not ready after ${timeout}s" + exit 1 + fi + done + + DEVICE_ID=$(adb devices | grep "emulator.*device" | awk '{print $1}') + echo "✅ Emulator fully booted: $DEVICE_ID" + + # Prepare emulator for CI use — keeps device responsive during idle period + echo "=== Preparing emulator for CI ===" + # Wait for device to stabilize after boot (transient offline state) + for i in $(seq 1 10); do + if adb -s $DEVICE_ID shell echo ok 2>/dev/null | grep -q ok; then + break + fi + echo "Device offline, retrying ($i/10)..." + sleep 3 + done + # Disable all animations (reduces CPU load and flakiness) + adb -s $DEVICE_ID shell settings put global window_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global transition_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global animator_duration_scale 0.0 + # Prevent screen from turning off (emulator simulates AC charging) + adb -s $DEVICE_ID shell settings put system screen_off_timeout 2147483647 + adb -s $DEVICE_ID shell svc power stayon true + # Wake screen and dismiss any lock screen + adb -s $DEVICE_ID shell input keyevent 82 + sleep 1 + # Dismiss any "System UI has stopped" or crash dialogs + adb -s $DEVICE_ID shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + # Clear logcat buffer so agent sees only fresh logs + adb -s $DEVICE_ID logcat -c 2>/dev/null || true + echo "Emulator preparation complete" + + echo "##vso[task.setvariable variable=DEVICE_UDID]$DEVICE_ID" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/platform-tools" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/emulator" + displayName: 'Create AVD and Boot Android Emulator' + retryCountOnTaskFailure: 1 + timeoutInMinutes: 15 + + # Install Node.js and Appium (same as ui-tests-steps.yml) + - task: UseNode@1 + inputs: + version: "24.x" displayName: 'Install Node.js' - - script: | - echo "Installing Appium and platform driver..." - - # Get npm global bin directory and add to PATH - NPM_BIN=$(npm config get prefix)/bin - echo "NPM global bin directory: $NPM_BIN" - export PATH="$NPM_BIN:$PATH" - - # Install Appium globally - npm install -g appium - - # Install both drivers — the agent may switch platforms if the bug - # only affects one platform (e.g., iOS bug triggered via Android run) - echo "Installing UiAutomator2 driver for Android..." - appium driver install uiautomator2 - echo "Installing XCUITest driver for iOS..." - appium driver install xcuitest - - # Verify installation - if ! which appium; then - echo "##vso[task.logissue type=error]Failed to install Appium" - exit 1 - fi - - APPIUM_PATH=$(which appium) - echo "Appium path: $APPIUM_PATH" - echo "Appium version: $(appium --version)" - echo "Appium drivers installed:" - appium driver list --installed - - # Export PATH for subsequent steps (Azure DevOps specific) - echo "##vso[task.prependpath]$NPM_BIN" - - echo "Appium installed successfully" + - pwsh: | + $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' + retryCountOnTaskFailure: 2 + timeoutInMinutes: 10 + env: + APPIUM_HOME: $(APPIUM_HOME) - script: | echo "Installing GitHub CLI..." @@ -435,11 +301,18 @@ stages: echo "##vso[task.logissue type=error]GH_CLI_TOKEN is not set. Please configure the pipeline variable." exit 1 fi - echo "$(GH_CLI_TOKEN)" | gh auth login --with-token - if ! gh auth status; then - echo "##vso[task.logissue type=error]GitHub CLI authentication failed" - exit 1 + # Use GH_TOKEN env var to avoid scope validation issues with newer gh versions + export GH_TOKEN="$(GH_CLI_TOKEN)" + gh auth status + if [ $? -ne 0 ]; then + # Fallback: try direct login + echo "$(GH_CLI_TOKEN)" | gh auth login --with-token 2>/dev/null || true + if ! gh auth status; then + echo "##vso[task.logissue type=error]GitHub CLI authentication failed" + exit 1 + fi fi + echo "GitHub CLI authenticated successfully" displayName: 'Authenticate GitHub CLI' env: GH_CLI_TOKEN: $(GH_CLI_TOKEN) @@ -447,89 +320,14 @@ stages: - script: | echo "Installing GitHub Copilot CLI..." npm install -g @github/copilot - if ! which copilot; then - echo "##vso[task.logissue type=error]Failed to install GitHub Copilot CLI" - exit 1 - fi + # Ensure npm global bin is on PATH for subsequent steps (Linux UseNode installs to toolcache) + COPILOT_BIN_DIR=$(dirname "$(which copilot)") + echo "Copilot binary at: $COPILOT_BIN_DIR/copilot" + echo "##vso[task.prependpath]$COPILOT_BIN_DIR" + copilot --version || true echo "Copilot CLI installed successfully" displayName: 'Install GitHub Copilot CLI' - # Restart Android emulator to ensure it's fresh before PR Reviewer runs - # The emulator can become unstable after running for a long time - - script: | - echo "=== Restarting Android Emulator for PR Reviewer ===" - - # Kill any existing emulator - echo "Killing existing emulator..." - pkill -f 'qemu-system' 2>/dev/null || true - sleep 5 - - # Restart ADB server - echo "Restarting ADB server..." - adb kill-server - sleep 2 - adb start-server - sleep 2 - - # Start fresh emulator - EMULATOR_LOG="/tmp/emulator-fresh.log" - echo "Starting fresh emulator..." - nohup $ANDROID_SDK_ROOT/emulator/emulator -avd Emulator_30 -no-window -no-snapshot -no-audio -no-boot-anim > "$EMULATOR_LOG" 2>&1 & - EMULATOR_PID=$! - echo "Emulator started with PID: $EMULATOR_PID" - - # Wait for device to appear - echo "Waiting for emulator device..." - device_timeout=300 - device_waited=0 - while ! adb devices | grep -q "emulator.*device"; do - sleep 5 - device_waited=$((device_waited + 5)) - if [ $device_waited -ge $device_timeout ]; then - echo "##vso[task.logissue type=error]Emulator device did not appear in time" - cat "$EMULATOR_LOG" 2>/dev/null | tail -30 - exit 1 - fi - echo "Waiting for device... ($device_waited/$device_timeout seconds)" - done - echo "Emulator device detected" - - # Wait for boot_completed - echo "Waiting for boot_completed..." - boot_timeout=600 - boot_waited=0 - while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do - sleep 5 - boot_waited=$((boot_waited + 5)) - if [ $boot_waited -ge $boot_timeout ]; then - echo "##vso[task.logissue type=error]Emulator did not boot in time" - exit 1 - fi - done - echo "Boot completed" - - # Wait for package manager - echo "Waiting for package manager service..." - pm_timeout=120 - pm_waited=0 - while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do - sleep 5 - pm_waited=$((pm_waited + 5)) - if [ $pm_waited -ge $pm_timeout ]; then - echo "##vso[task.logissue type=error]Package manager service did not start" - adb shell service list 2>/dev/null | head -20 - exit 1 - fi - echo "Waiting for package manager... ($pm_waited/$pm_timeout seconds)" - done - - echo "=== Fresh Android Emulator Ready! ===" - adb devices -l - displayName: 'Restart Android Emulator (Fresh)' - condition: eq('${{ parameters.Platform }}', 'android') - timeoutInMinutes: 15 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) # Boot iOS Simulator (only for iOS platform) # UI test baseline screenshots are captured on iPhone Xs - must use same device @@ -623,10 +421,86 @@ stages: condition: eq('${{ parameters.Platform }}', 'ios') timeoutInMinutes: 5 + # Warm up the emulator right before the agent runs. + # The emulator may have been idle for 15-30 min while Appium/Node/CLI were installed. + # Without this, SystemUI can ANR when the agent first touches it. + - script: | + set -e + DEVICE_ID="$(DEVICE_UDID)" + if [ -z "$DEVICE_ID" ]; then + echo "No DEVICE_UDID set — skipping warmup" + exit 0 + fi + + echo "=== Emulator warmup before agent ===" + # Verify device is still connected + if ! adb -s "$DEVICE_ID" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then + echo "Device not responding. Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + timeout 60 adb wait-for-device + fi + + # Dismiss ANR dialogs and wake screen — run twice for reliability + for PASS in 1 2; do + echo "--- Warmup pass $PASS ---" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_WAKEUP 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_MENU 2>/dev/null || true + sleep 1 + + # Dismiss system dialogs (ANR, crash, etc.) + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_ENTER 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + done + + # Check for lingering ANR in window state + if adb -s "$DEVICE_ID" shell dumpsys window 2>/dev/null | grep -qi "Application Not Responding\|ANR"; then + echo "⚠️ ANR dialog still present — force-dismissing with HOME + BACK" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME 2>/dev/null || true + sleep 2 + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + fi + + # Open and close Settings to exercise the system and confirm responsiveness + adb -s "$DEVICE_ID" shell am start -a android.settings.SETTINGS 2>/dev/null || true + sleep 3 + adb -s "$DEVICE_ID" shell am force-stop com.android.settings 2>/dev/null || true + + # Final dialog sweep + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + + # Clear logcat so agent gets clean logs + adb -s "$DEVICE_ID" logcat -c 2>/dev/null || true + + echo "✅ Emulator warmed up and responsive" + displayName: 'Warm Up Android Emulator' + condition: and(succeeded(), eq('${{ parameters.Platform }}', 'android')) + timeoutInMinutes: 3 + - script: | echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..." echo "Reviewing PR #${{ parameters.PRNumber }}..." + # Ensure copilot CLI is accessible to pwsh subprocess. + # npm global install on Linux goes to UseNode@1 toolcache path which may not + # be on PATH inside pwsh even when exported from bash. Create a symlink in + # /usr/local/bin which is universally on PATH for all shells. + COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1) + if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then + sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot + echo "Symlinked copilot to /usr/local/bin/copilot" + fi + echo "copilot location: $(which copilot 2>/dev/null || echo 'not found')" + # Verify pwsh can find it + pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"' + # Configure git identity (required for merge operations on self-hosted agents) git config user.email "copilot-ci@microsoft.com" git config user.name "Copilot CI" @@ -636,7 +510,12 @@ stages: # AcesShared agents may have a newer Xcode than the .NET iOS SDK expects cp Directory.Build.Override.props.in Directory.Build.Override.props # Insert ValidateXcodeVersion before closing tag - sed -i '' 's|| false\n|' Directory.Build.Override.props + # GNU sed (Linux) uses -i without suffix; BSD sed (macOS) uses -i '' + if [[ "$(uname)" == "Linux" ]]; then + sed -i 's|| false\n|' Directory.Build.Override.props + else + sed -i '' 's|| false\n|' Directory.Build.Override.props + fi # Create artifacts directory for Copilot outputs mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs @@ -645,7 +524,7 @@ stages: # The script will merge the PR into the current branch # -PostSummaryComment and -RunFinalize handle posting comments set +e - pwsh .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -RunFinalize -PostSummaryComment -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" + pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -RunFinalize -PostSummaryComment -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" COPILOT_EXIT_CODE=$? set -e diff --git a/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs b/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs index fd4ccadfdd17..449ffeaf72d1 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/Adapters/EmptyViewAdapter.cs @@ -316,6 +316,11 @@ void UpdateHeaderFooterHeight(object item, bool isHeader) if (item is DataTemplate dataTemplate) { var content = dataTemplate.CreateContent() as IView; + + if (content?.Handler is null) + { + TemplateHelpers.GetHandler(content as View, ItemsView.FindMauiContext()); + } size = content.Measure(double.PositiveInfinity, double.PositiveInfinity); } diff --git a/src/Controls/src/Core/TypedBinding.cs b/src/Controls/src/Core/TypedBinding.cs index 90b1a1d9f30e..4a5ad5545fb2 100644 --- a/src/Controls/src/Core/TypedBinding.cs +++ b/src/Controls/src/Core/TypedBinding.cs @@ -147,7 +147,6 @@ public TypedBinding(Func getter, Actio List> _ancestryChain; bool _isBindingContextRelativeSource; BindingMode _cachedMode; - bool _isSubscribed; bool _isTSource; // cached type check result object _cachedDefaultValue; // cached default value bool _hasDefaultValue; @@ -289,7 +288,6 @@ internal override void Unapply(bool fromBindingContextChanged = false) if (_handlers != null) Unsubscribe(); - _isSubscribed = false; _cachedMode = BindingMode.Default; _hasDefaultValue = false; _cachedDefaultValue = null; @@ -332,11 +330,12 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp var needsGetter = (mode == BindingMode.TwoWay && !fromTarget) || mode == BindingMode.OneWay || mode == BindingMode.OneTime; - // Only subscribe once per binding lifetime - if (!_isSubscribed && isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) + // Subscribe on every Apply so that intermediate objects that changed are re-subscribed. + // Subscribe() is idempotent: it diffs old vs new subscription targets and only + // updates what changed, so calling this repeatedly is safe. + if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) { Subscribe((TSource)sourceObject); - _isSubscribed = true; } if (needsGetter) diff --git a/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs b/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs index 5fbf2f11f096..e8ceee33570a 100644 --- a/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs @@ -1773,6 +1773,87 @@ public string Title } } + [Fact] + //https://github.com/dotnet/maui/issues/34428 + public void TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull() + { + // Regression: when an intermediate object in the path starts as null and later becomes + // non-null, the binding must re-establish subscriptions to nested properties. + // Previously, the _isSubscribed flag prevented re-subscribing after the first Apply. + + var vm = new ComplexMockViewModel + { + Model = null // Start with null intermediate + }; + + var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null); + + var binding = new TypedBinding( + cvm => cvm.Model is { } m ? (m.Text, true) : (null, false), + (cvm, t) => { if (cvm.Model is { } m) m.Text = t; }, + new[] { + new Tuple, string>(cvm => cvm, "Model"), + new Tuple, string>(cvm => cvm.Model, "Text") + }) + { Mode = BindingMode.OneWay }; + + var bindable = new MockBindable(); + bindable.SetBinding(property, binding); + bindable.BindingContext = vm; + + // Initially null model → binding returns null/default + Assert.Null(bindable.GetValue(property)); + + // Set Model to non-null → binding should pick up the value + vm.Model = new ComplexMockViewModel { Text = "Initial" }; + Assert.Equal("Initial", (string)bindable.GetValue(property)); + + // Change nested property → binding MUST update (this was the regression) + vm.Model.Text = "Updated"; + Assert.Equal("Updated", (string)bindable.GetValue(property)); + } + + [Fact] + //https://github.com/dotnet/maui/issues/34428 + public void TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced() + { + // When the intermediate object is replaced (non-null → different non-null object), + // the binding must switch subscriptions to the new object. + + var child1 = new ComplexMockViewModel { Text = "Child1" }; + var child2 = new ComplexMockViewModel { Text = "Child2" }; + var vm = new ComplexMockViewModel { Model = child1 }; + + var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null); + + var binding = new TypedBinding( + cvm => cvm.Model is { } m ? (m.Text, true) : (null, false), + (cvm, t) => { if (cvm.Model is { } m) m.Text = t; }, + new[] { + new Tuple, string>(cvm => cvm, "Model"), + new Tuple, string>(cvm => cvm.Model, "Text") + }) + { Mode = BindingMode.OneWay }; + + var bindable = new MockBindable(); + bindable.SetBinding(property, binding); + bindable.BindingContext = vm; + + Assert.Equal("Child1", (string)bindable.GetValue(property)); + + // Replace intermediate with a different object + vm.Model = child2; + Assert.Equal("Child2", (string)bindable.GetValue(property)); + + // Changing the OLD intermediate should NOT fire the binding + child1.Text = "OldChildChanged"; + Assert.Equal("Child2", (string)bindable.GetValue(property)); + + // Changing the NEW intermediate SHOULD fire the binding + child2.Text = "Child2Updated"; + Assert.Equal("Child2Updated", (string)bindable.GetValue(property)); + } + [Fact] //https://github.com/xamarin/Microsoft.Maui.Controls/issues/3650 //https://github.com/xamarin/Microsoft.Maui.Controls/issues/3613 diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue28101.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue28101.cs new file mode 100644 index 000000000000..1d831acfacba --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue28101.cs @@ -0,0 +1,54 @@ +using Microsoft.Maui.Platform; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 28101, "CollectionView Footer Becomes Scrollable When EmptyView is Active on Android", PlatformAffected.Android)] + public class Issue28101 : TestContentPage + { + protected override void Init() + { + Grid grid = new Grid(); + CollectionView collectionView = new CollectionView + { + EmptyView = new Label + { + Padding = new Thickness(20, 5, 5, 5), + Text = "Empty" + } + }; + + collectionView.HeaderTemplate = new DataTemplate(() => + { + Grid gridHeader = new Grid(); + + Label labelHeader = new Label + { + FontSize = 20, + BackgroundColor = Colors.HotPink, + Text = "This Is A Header" + }; + + gridHeader.Children.Add(labelHeader); + return gridHeader; + }); + + collectionView.FooterTemplate = new DataTemplate(() => + { + Grid gridFooter = new Grid(); + + Label labelFooter = new Label + { + Text = "This Is A Footer", + BackgroundColor = Colors.HotPink, + FontSize = 20, + }; + + gridFooter.Children.Add(labelFooter); + return gridFooter; + }); + + grid.Children.Add(collectionView); + Content = grid; + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28101.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28101.cs new file mode 100644 index 000000000000..dc44200cfe9a --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28101.cs @@ -0,0 +1,20 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue28101 : _IssuesUITest +{ + public Issue28101(TestDevice device) : base(device) + { + } + public override string Issue => "CollectionView Footer Becomes Scrollable When EmptyView is Active on Android"; + + [Test] + [Category(UITestCategories.CollectionView)] + public void FooterTemplateShouldNotScrollWhenEmptyViewIsDisplayed() + { + App.WaitForElement("This Is A Footer"); + } +}