Modules/businessdev.ALbuild.RuntimePackages/Private/ConvertTo-BcRuntimeJUnit.ps1

function ConvertTo-BcRuntimeJUnit {
    <#
    .SYNOPSIS
        Renders per-app runtime-package results as JUnit XML.
 
    .DESCRIPTION
        Azure DevOps shows a stage as one icon, so a run that builds a dozen products across dozens of
        platform versions collapses into a single green or red dot - and finding which app failed on
        which version means reading a multi-thousand-line log. Giving every app its own stage would
        restore the icons but destroy the point of the factory: a stage is a job, a job is a container,
        and one container per (app, platform version) is the ~2300-container arrangement the factory
        exists to replace.
 
        JUnit results are the way out. Published with PublishTestResults@2, each app becomes an entry in
        the Tests tab: green when its runtime package was produced, red with the actual error when it
        failed, skipped when the package already existed. It is per-app, filterable, and kept across runs
        so a version that starts failing is visible as a change rather than as a wall of text.
 
        Suite = platform version, case = app. That grouping matches how the work is actually shaped and
        how someone reads it: "what broke on BC 27.3?".
 
    .PARAMETER Result
        Per-version results with PlatformVersion, Seconds and Products[] (Name, Status, Error, Seconds).
        Status is Succeeded, Failed or Skipped.
 
    .OUTPUTS
        System.String - JUnit XML.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Result
    )

    $esc = { param([string] $Value) [System.Security.SecurityElement]::Escape("$Value") }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('<?xml version="1.0" encoding="utf-8"?>')
    [void]$sb.AppendLine('<testsuites>')

    foreach ($version in @($Result)) {
        $products = @(Get-BcRuntimeProperty -InputObject $version -Name 'Products' -Default @())
        $failures = @($products | Where-Object { $_.Status -eq 'Failed' }).Count
        $skipped = @($products | Where-Object { $_.Status -eq 'Skipped' }).Count
        $seconds = [double](Get-BcRuntimeProperty -InputObject $version -Name 'Seconds' -Default 0)

        [void]$sb.AppendLine((' <testsuite name="{0}" tests="{1}" failures="{2}" skipped="{3}" time="{4}">' -f
            (& $esc "BC $($version.PlatformVersion)"), $products.Count, $failures, $skipped,
                ([string]::Format([cultureinfo]::InvariantCulture, '{0:0.###}', $seconds))))

        foreach ($p in $products) {
            $pSeconds = [double](Get-BcRuntimeProperty -InputObject $p -Name 'Seconds' -Default 0)
            # classname = platform version so the Tests tab groups by version even when a consumer
            # flattens the suites.
            [void]$sb.AppendLine((' <testcase classname="{0}" name="{1}" time="{2}">' -f
                (& $esc "BC $($version.PlatformVersion)"), (& $esc $p.Name),
                    ([string]::Format([cultureinfo]::InvariantCulture, '{0:0.###}', $pSeconds))))
            switch ($p.Status) {
                'Failed' {
                    $message = "$(Get-BcRuntimeProperty -InputObject $p -Name 'Error' -Default 'Runtime package creation failed.')"
                    [void]$sb.AppendLine((' <failure message="{0}">{1}</failure>' -f (& $esc $message), (& $esc $message)))
                }
                'Skipped' {
                    $message = "$(Get-BcRuntimeProperty -InputObject $p -Name 'Error' -Default 'Already published.')"
                    [void]$sb.AppendLine((' <skipped message="{0}" />' -f (& $esc $message)))
                }
            }
            [void]$sb.AppendLine(' </testcase>')
        }
        [void]$sb.AppendLine(' </testsuite>')
    }

    [void]$sb.AppendLine('</testsuites>')
    return $sb.ToString()
}