Modules/businessdev.ALbuild.Apps/Public/Resolve-BcTestFailureSource.ps1
|
function Resolve-BcTestFailureSource { <# .SYNOPSIS Maps the AL callstack of a failed Business Central test onto files and lines in your workspace. .DESCRIPTION A failed BC test reports a message and a callstack, and the callstack names objects and procedures but not files. Worse, the line number in a frame is NOT a file line: it is counted from the procedure's own declaration. So "line 6" in a codeunit whose procedure starts at file line 10 means file line 16 - and without that arithmetic the number looks wrong enough to be ignored. This resolves each frame to the real file and line, so a failure points at the statement instead of at a codeunit. The difference between "somewhere in this object" and "line 214" is the difference between searching and fixing. MEASURED, NOT ASSUMED. The offset rule was established against a real failing test on Business Central 28.4: the failing assertion sat at file line 16, its procedure was declared at line 10, and the frame reported line 6. Every line between them counts - var declarations and 'begin' included. A frame from an app whose source is not in the workspace (Library Assert, the test runner, any Microsoft object) cannot be resolved. Those are reported as unresolved rather than guessed at: a wrong file reference wastes more time than a missing one. .PARAMETER ResultPath The JUnit/XUnit result file written by the test run. .PARAMETER WorkspaceRoot Repository root holding the AL source to resolve against. .OUTPUTS PSCustomObject[] - one per failed test: Name, Message, Frames (Object, Procedure, ReportedLine, File, Line, App, Resolved). .EXAMPLE Resolve-BcTestFailureSource -ResultPath ./TestResults.xml -WorkspaceRoot . Reports each failure with the file and line of every frame that belongs to your own code. #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [string] $ResultPath, [Parameter(Mandatory)] [string] $WorkspaceRoot ) if (-not (Test-Path -LiteralPath $ResultPath)) { throw "Test result file '$ResultPath' not found." } if (-not (Test-Path -LiteralPath $WorkspaceRoot)) { throw "Workspace root '$WorkspaceRoot' not found." } $index = Get-BcAlObjectIndex -WorkspaceRoot $WorkspaceRoot [xml] $doc = Get-Content -LiteralPath $ResultPath -Raw $out = [System.Collections.Generic.List[object]]::new() foreach ($case in $doc.SelectNodes("//*[local-name()='testcase']")) { $failure = $case.SelectSingleNode("./*[local-name()='failure'] | ./*[local-name()='error']") if (-not $failure) { continue } $name = '' if ($case.Attributes) { foreach ($a in $case.Attributes) { if ($a.Name -ieq 'name') { $name = $a.Value } } } $message = '' if ($failure.Attributes) { foreach ($a in $failure.Attributes) { if ($a.Name -ieq 'message') { $message = $a.Value } } } $frames = [System.Collections.Generic.List[object]]::new() foreach ($line in ("$($failure.InnerText)" -split "`r?`n")) { $frame = ConvertTo-BcAlStackFrame -Line $line -ObjectIndex $index if ($frame) { $frames.Add($frame) } } $out.Add([PSCustomObject]@{ Name = $name Message = $message Frames = $frames.ToArray() }) } # XUnit layout: <test result="Fail"> with a <stack-trace> sibling of <message>. foreach ($test in $doc.SelectNodes("//*[local-name()='test']")) { $result = '' if ($test.Attributes) { foreach ($a in $test.Attributes) { if ($a.Name -ieq 'result') { $result = $a.Value } } } if ($result -notmatch '^(?i)fail') { continue } $name = '' if ($test.Attributes) { foreach ($a in $test.Attributes) { if ($a.Name -ieq 'name' -or $a.Name -ieq 'method') { $name = $a.Value } } } $msgNode = $test.SelectSingleNode(".//*[local-name()='message']") $stackNode = $test.SelectSingleNode(".//*[local-name()='stack-trace']") $frames = [System.Collections.Generic.List[object]]::new() if ($stackNode) { foreach ($line in ("$($stackNode.InnerText)" -split "`r?`n")) { $frame = ConvertTo-BcAlStackFrame -Line $line -ObjectIndex $index if ($frame) { $frames.Add($frame) } } } $out.Add([PSCustomObject]@{ Name = $name Message = $(if ($msgNode) { $msgNode.InnerText } else { '' }) Frames = $frames.ToArray() }) } return $out.ToArray() } |