-
Notifications
You must be signed in to change notification settings - Fork 8.3k
[release/v7.5] Add GitHub Actions annotations for Pester test failures #26836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,7 @@ $testIgnoredCount = 0 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testSkippedCount = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testInvalidCount = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Process test results and generate annotations for failures | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Get-ChildItem -Path "${TestResultsFolder}/*.xml" -Recurse | ForEach-Object { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $results = [xml] (get-content $_.FullName) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -35,6 +36,61 @@ Get-ChildItem -Path "${TestResultsFolder}/*.xml" -Recurse | ForEach-Object { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testIgnoredCount += [int]$results.'test-results'.ignored | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testSkippedCount += [int]$results.'test-results'.skipped | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testInvalidCount += [int]$results.'test-results'.invalid | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Generate GitHub Actions annotations for test failures | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Select failed test cases | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ("System.Xml.XmlDocumentXPathExtensions" -as [Type]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $failures = [System.Xml.XmlDocumentXPathExtensions]::SelectNodes($results.'test-results', './/test-case[@result = "Failure"]') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $failures = $results.SelectNodes('.//test-case[@result = "Failure"]') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| foreach ($testfail in $failures) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $description = $testfail.description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $testName = $testfail.name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $message = $testfail.failure.message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $stack_trace = $testfail.failure.'stack-trace' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Parse stack trace to get file and line info | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $fileInfo = Get-PesterFailureFileInfo -StackTraceString $stack_trace | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($fileInfo.File) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Convert absolute path to relative path for GitHub Actions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $filePath = $fileInfo.File | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # GitHub Actions expects paths relative to the workspace root | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($env:GITHUB_WORKSPACE) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $workspacePath = $env:GITHUB_WORKSPACE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($filePath.StartsWith($workspacePath)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $filePath = $filePath.Substring($workspacePath.Length).TrimStart('/', '\') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Normalize to forward slashes for consistency | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $filePath = $filePath -replace '\\', '/' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+64
to
+69
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $workspacePath = $env:GITHUB_WORKSPACE | |
| if ($filePath.StartsWith($workspacePath)) { | |
| $filePath = $filePath.Substring($workspacePath.Length).TrimStart('/', '\') | |
| # Normalize to forward slashes for consistency | |
| $filePath = $filePath -replace '\\', '/' | |
| } | |
| $workspacePath = [System.IO.Path]::GetFullPath( | |
| $env:GITHUB_WORKSPACE.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) | |
| ) | |
| $fileFullPath = [System.IO.Path]::GetFullPath($filePath) | |
| $relativePath = $null | |
| try { | |
| $relativePath = [System.IO.Path]::GetRelativePath($workspacePath, $fileFullPath) | |
| } | |
| catch { | |
| # If GetRelativePath fails for any reason, fall back to manual logic below. | |
| } | |
| $useRelativePath = $false | |
| if ($relativePath -and -not [System.IO.Path]::IsPathRooted($relativePath)) { | |
| $parentTraversalPrefix = '..' + [System.IO.Path]::DirectorySeparatorChar | |
| if ($relativePath -ne '..' -and -not $relativePath.StartsWith($parentTraversalPrefix)) { | |
| $useRelativePath = $true | |
| } | |
| } | |
| if ($useRelativePath) { | |
| $filePath = $relativePath | |
| } | |
| else { | |
| # Fallback: if the file is under the workspace (case-insensitive), compute a safe relative path. | |
| if ($fileFullPath.StartsWith($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) { | |
| $filePath = $fileFullPath.Substring($workspacePath.Length).TrimStart('\', '/') | |
| } | |
| else { | |
| # As a last resort, keep the normalized absolute path. | |
| $filePath = $fileFullPath | |
| } | |
| } | |
| # Normalize to forward slashes for consistency | |
| $filePath = $filePath -replace '\\', '/' |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The annotation title and message construction follows the same escaping pattern as the existing Write-Log function (line 2920 in build.psm1), which only escapes newlines and carriage returns. However, according to GitHub Actions workflow command documentation, additional characters should be escaped for complete safety: percent signs (%), commas (,), and colons (:) have special meaning in workflow commands.
The title on line 73 concatenates $description and $testName which may contain these special characters. The message on line 76 uses the same incomplete escaping pattern as Write-Log.
While this matches existing codebase patterns, it could cause annotations to be malformed or fail to display correctly when test names or messages contain special characters. Consider enhancing the escaping to include: % → %25, , → %2C, and : → %3A for more robust handling, or document this as a known limitation.
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The foreach loop does not check if $failures is null or empty before iterating. While the XPath query will return an empty collection rather than null in most cases, if the test-results XML structure is malformed or unexpected, the SelectNodes call could potentially return null, causing errors.
Add a null/empty check before the foreach loop to handle edge cases where no failures node collection is returned, similar to the pattern used in Test-PSPesterResults function at line 2010 which checks the failure count before iterating.
| foreach ($testfail in $failures) { | |
| $description = $testfail.description | |
| $testName = $testfail.name | |
| $message = $testfail.failure.message | |
| $stack_trace = $testfail.failure.'stack-trace' | |
| # Parse stack trace to get file and line info | |
| $fileInfo = Get-PesterFailureFileInfo -StackTraceString $stack_trace | |
| if ($fileInfo.File) { | |
| # Convert absolute path to relative path for GitHub Actions | |
| $filePath = $fileInfo.File | |
| # GitHub Actions expects paths relative to the workspace root | |
| if ($env:GITHUB_WORKSPACE) { | |
| $workspacePath = $env:GITHUB_WORKSPACE | |
| if ($filePath.StartsWith($workspacePath)) { | |
| $filePath = $filePath.Substring($workspacePath.Length).TrimStart('/', '\') | |
| # Normalize to forward slashes for consistency | |
| $filePath = $filePath -replace '\\', '/' | |
| } | |
| } | |
| # Create annotation title | |
| $annotationTitle = "Test Failure: $description / $testName" | |
| # Build the annotation message | |
| $annotationMessage = $message -replace "`n", "%0A" -replace "`r" | |
| # Build and output the workflow command | |
| $workflowCommand = "::error file=$filePath" | |
| if ($fileInfo.Line) { | |
| $workflowCommand += ",line=$($fileInfo.Line)" | |
| } | |
| $workflowCommand += ",title=$annotationTitle::$annotationMessage" | |
| Write-Host $workflowCommand | |
| # Output a link to the test run | |
| if ($env:GITHUB_SERVER_URL -and $env:GITHUB_REPOSITORY -and $env:GITHUB_RUN_ID) { | |
| $logUrl = "$($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID)" | |
| Write-Host "Test logs: $logUrl" | |
| if ($failures -and $failures.Count -gt 0) { | |
| foreach ($testfail in $failures) { | |
| $description = $testfail.description | |
| $testName = $testfail.name | |
| $message = $testfail.failure.message | |
| $stack_trace = $testfail.failure.'stack-trace' | |
| # Parse stack trace to get file and line info | |
| $fileInfo = Get-PesterFailureFileInfo -StackTraceString $stack_trace | |
| if ($fileInfo.File) { | |
| # Convert absolute path to relative path for GitHub Actions | |
| $filePath = $fileInfo.File | |
| # GitHub Actions expects paths relative to the workspace root | |
| if ($env:GITHUB_WORKSPACE) { | |
| $workspacePath = $env:GITHUB_WORKSPACE | |
| if ($filePath.StartsWith($workspacePath)) { | |
| $filePath = $filePath.Substring($workspacePath.Length).TrimStart('/', '\') | |
| # Normalize to forward slashes for consistency | |
| $filePath = $filePath -replace '\\', '/' | |
| } | |
| } | |
| # Create annotation title | |
| $annotationTitle = "Test Failure: $description / $testName" | |
| # Build the annotation message | |
| $annotationMessage = $message -replace "`n", "%0A" -replace "`r" | |
| # Build and output the workflow command | |
| $workflowCommand = "::error file=$filePath" | |
| if ($fileInfo.Line) { | |
| $workflowCommand += ",line=$($fileInfo.Line)" | |
| } | |
| $workflowCommand += ",title=$annotationTitle::$annotationMessage" | |
| Write-Host $workflowCommand | |
| # Output a link to the test run | |
| if ($env:GITHUB_SERVER_URL -and $env:GITHUB_REPOSITORY -and $env:GITHUB_RUN_ID) { | |
| $logUrl = "$($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID)" | |
| Write-Host "Test logs: $logUrl" | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1864,6 +1864,69 @@ $stack_trace | |
|
|
||
| } | ||
|
|
||
| function Get-PesterFailureFileInfo | ||
| { | ||
| [CmdletBinding()] | ||
| param ( | ||
| [Parameter(Mandatory)] | ||
| [string]$StackTraceString | ||
| ) | ||
|
|
||
| # Parse stack trace to extract file path and line number | ||
| # Common patterns: | ||
| # "at line: 123 in C:\path\to\file.ps1" (Pester 4) | ||
| # "at C:\path\to\file.ps1:123" | ||
| # "at <ScriptBlock>, C:\path\to\file.ps1: line 123" | ||
| # "at 1 | Should -Be 2, /path/to/file.ps1:123" (Pester 5) | ||
| # "at 1 | Should -Be 2, C:\path\to\file.ps1:123" (Pester 5 Windows) | ||
|
|
||
| $result = @{ | ||
| File = $null | ||
| Line = $null | ||
| } | ||
|
|
||
| if ([string]::IsNullOrWhiteSpace($StackTraceString)) { | ||
| return $result | ||
| } | ||
|
|
||
| # Try pattern: "at line: 123 in <path>" (Pester 4) | ||
| if ($StackTraceString -match 'at line:\s*(\d+)\s+in\s+(.+?)(?:\r|\n|$)') { | ||
| $result.Line = $matches[1] | ||
| $result.File = $matches[2].Trim() | ||
| return $result | ||
| } | ||
|
|
||
| # Try pattern: ", <path>:123" (Pester 5 format) | ||
| # This handles both Unix paths (/path/file.ps1:123) and Windows paths (C:\path\file.ps1:123) | ||
| if ($StackTraceString -match ',\s*((?:[A-Za-z]:)?[\/\\].+?\.ps[m]?1):(\d+)') { | ||
| $result.File = $matches[1].Trim() | ||
| $result.Line = $matches[2] | ||
| return $result | ||
| } | ||
|
|
||
| # Try pattern: "at <path>:123" (without comma) | ||
| # Handle both absolute Unix and Windows paths | ||
| if ($StackTraceString -match 'at\s+((?:[A-Za-z]:)?[\/\\][^,]+?\.ps[m]?1):(\d+)(?:\r|\n|$)') { | ||
| $result.File = $matches[1].Trim() | ||
| $result.Line = $matches[2] | ||
| return $result | ||
| } | ||
|
|
||
| # Try pattern: "<path>: line 123" | ||
| if ($StackTraceString -match '((?:[A-Za-z]:)?[\/\\][^,]+?\.ps[m]?1):\s*line\s+(\d+)(?:\r|\n|$)') { | ||
| $result.File = $matches[1].Trim() | ||
| $result.Line = $matches[2] | ||
| return $result | ||
| } | ||
|
|
||
| # Try to extract just the file path if no line number found | ||
| if ($StackTraceString -match '(?:at\s+|in\s+)?((?:[A-Za-z]:)?[\/\\].+?\.ps[m]?1)') { | ||
| $result.File = $matches[1].Trim() | ||
| } | ||
|
Comment on lines
+1899
to
+1925
|
||
|
|
||
| return $result | ||
| } | ||
|
|
||
| function Test-XUnitTestResults | ||
| { | ||
| param( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code assumes that $testfail.description, $testfail.name, $testfail.failure.message, and $testfail.failure.'stack-trace' will always be present and not null. If the XML structure is missing any of these elements (e.g., a test failure without a description or message), this could cause issues when constructing the annotation.
Consider adding null checks or using null-coalescing/default values to handle cases where these properties might be missing from the XML, similar to defensive programming patterns seen elsewhere in the codebase.