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()
}