Modules/businessdev.ALbuild.Apps/Resources/TestRunner/Invoke-BcAlTestRun.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    In-container AL test driver. Runs the AL Test Tool and writes JUnit/XUnit results.
 
.DESCRIPTION
    This is an ALbuild payload script: it runs *inside* the Business Central Windows container
    only (it is copied in next to BcTestClientContext.ps1 and invoked through
    Invoke-BcContainerCommand). It is a clean, from-scratch reimplementation of the AL test
    execution flow:
 
      1. Resolve the client DLLs that ship with the server (UI client + Newtonsoft) and load them.
      2. Read CustomSettings.config to discover the server instance, credential type and web URL,
         and build the client-services service URL against localhost.
      3. Open a session, and for each test extension open the AL Test Tool page (130455), set the
         suite, the extension id and the test-runner codeunit, clear previous results, then drive
         the modern "RunNextTest" loop reading the TestResultJson control until all tests have run.
      4. Emit a JUnit (and optionally XUnit) result file that ALbuild parses on the host.
 
    Only the modern test page (130455, Business Central 15+) is supported; the legacy C/AL test
    page is intentionally not carried forward.
 
.NOTES
    Container-only. Cannot be exercised from a non-Windows host or without Docker; validated on a
    Windows + Docker BC container.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'Password', Justification = 'In-container payload: the credential is marshalled across the docker exec boundary as text and reconstructed here; it is never logged.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'In-container payload: the password arrives as text and must be turned back into a PSCredential to open the client session.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Parameters model the full runner contract; some are used only for specific auth/output modes.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Local payload helpers describe collections (DllPaths, Assemblies, Settings) and read clearly as plural.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-Bc*Document build in-memory XML documents; they have no external side effects to confirm.')]
param(
    [Parameter(Mandatory)] [object[]] $TestApps,
    [string] $TestSuite = 'DEFAULT',
    [Parameter(Mandatory)] [string] $JUnitResultFileName,
    [string] $XUnitResultFileName = '',
    [string] $Tenant = 'default',
    [string] $CompanyName = '',
    [ValidateSet('Windows', 'NavUserPassword', 'AAD')] [string] $Auth = 'NavUserPassword',
    [string] $UserName = '',
    [string] $Password = '',
    [string] $AccessToken = '',
    [string] $Culture = 'en-US',
    [string] $Timezone = '',
    [int] $TestPage = 130455,
    [ValidateSet('no', 'error', 'warning')] [string] $AzureDevOps = 'no',
    [int] $InteractionTimeoutMinutes = 480,
    [switch] $DebugMode
)

$ErrorActionPreference = 'Stop'

function Get-BcClientDllPaths {
    $newtonSoftDllPath = "C:\Program Files\Microsoft Dynamics NAV\*\Service\Management\Newtonsoft.Json.dll"
    if (-not (Test-Path $newtonSoftDllPath)) {
        $newtonSoftDllPath = "C:\Program Files\Microsoft Dynamics NAV\*\Service\Newtonsoft.Json.dll"
    }
    $newtonSoftDllPath = (Get-Item $newtonSoftDllPath).FullName
    $clientDllPath = "C:\Test Assemblies\Microsoft.Dynamics.Framework.UI.Client.dll"
    if (-not (Test-Path $clientDllPath)) {
        throw "The client DLL '$clientDllPath' was not found. Import the test toolkit before running tests."
    }
    return [PSCustomObject]@{ NewtonSoft = $newtonSoftDllPath; Client = $clientDllPath }
}

function Import-BcClientAssemblies {
    param([string] $NewtonSoftDllPath, [string] $ClientDllPath)

    Add-Type -Path $NewtonSoftDllPath
    $antiSsrfDll = Join-Path ([System.IO.Path]::GetDirectoryName($ClientDllPath)) 'Microsoft.Internal.AntiSSRF.dll'
    if (Test-Path $antiSsrfDll) {
        $threading = [Reflection.Assembly]::LoadFile((Join-Path ([System.IO.Path]::GetDirectoryName($ClientDllPath)) 'System.Threading.Tasks.Extensions.dll'))
        $resolver = [System.ResolveEventHandler] {
            param($s, $e)
            if ($e.Name -like 'System.Threading.Tasks.Extensions, Version=*, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51') { return $threading }
            return $null
        }
        [System.AppDomain]::CurrentDomain.add_AssemblyResolve($resolver)
        try { Add-Type -Path $antiSsrfDll } finally { [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($resolver) }
    }
    Add-Type -Path $ClientDllPath
}

function Get-BcServerSettings {
    $serviceFolder = (Get-Item "C:\Program Files\Microsoft Dynamics NAV\*\Service").FullName
    $customConfigFile = Join-Path $serviceFolder 'CustomSettings.config'
    [xml] $customConfig = [System.IO.File]::ReadAllText($customConfigFile)
    $publicWebBaseUrl = $customConfig.SelectSingleNode("//appSettings/add[@key='PublicWebBaseUrl']").Value.TrimEnd('/')
    $credentialType = $customConfig.SelectSingleNode("//appSettings/add[@key='ClientServicesCredentialType']").Value
    $serverInstance = $customConfig.SelectSingleNode("//appSettings/add[@key='ServerInstance']").Value
    return [PSCustomObject]@{
        PublicWebBaseUrl = $publicWebBaseUrl
        CredentialType   = $credentialType
        ServerInstance   = $serverInstance
    }
}

function Get-BcServiceUrl {
    param([string] $PublicWebBaseUrl, [string] $Tenant, [string] $CompanyName)
    $uri = [Uri]::new($PublicWebBaseUrl)
    $serviceUrl = "$($uri.Scheme)://localhost:$($uri.Port)$($uri.PathAndQuery)/cs?tenant=$Tenant"
    if ($CompanyName) { $serviceUrl += "&company=$([Uri]::EscapeDataString($CompanyName))" }
    return $serviceUrl
}

function New-BcJUnitDocument {
    param([string] $Path)
    if (Test-Path $Path -PathType Leaf) { Remove-Item $Path -Force }
    $doc = New-Object System.Xml.XmlDocument
    $doc.AppendChild($doc.CreateXmlDeclaration('1.0', 'UTF-8', $null)) | Out-Null
    $root = $doc.CreateElement('testsuites')
    $doc.AppendChild($root) | Out-Null
    return $doc
}

function New-BcXUnitDocument {
    param([string] $Path)
    if (Test-Path $Path -PathType Leaf) { Remove-Item $Path -Force }
    $doc = New-Object System.Xml.XmlDocument
    $doc.AppendChild($doc.CreateXmlDeclaration('1.0', 'UTF-8', $null)) | Out-Null
    $root = $doc.CreateElement('assemblies')
    $doc.AppendChild($root) | Out-Null
    return $doc
}

function Get-BcDateTime {
    param($Value)
    if ($Value -is [DateTime]) { return $Value }
    return [DateTime]::Parse($Value, [System.Globalization.CultureInfo]::InvariantCulture)
}

# --- Set up -----------------------------------------------------------------------------------

$dlls = Get-BcClientDllPaths
Import-BcClientAssemblies -NewtonSoftDllPath $dlls.NewtonSoft -ClientDllPath $dlls.Client
. (Join-Path $PSScriptRoot 'BcTestClientContext.ps1') -ClientDllPath $dlls.Client

$server = Get-BcServerSettings
$serviceUrl = Get-BcServiceUrl -PublicWebBaseUrl $server.PublicWebBaseUrl -Tenant $Tenant -CompanyName $CompanyName
$interactionTimeout = [timespan]::FromMinutes($InteractionTimeoutMinutes)

if (-not $Auth) { $Auth = $server.CredentialType }

# Disable SSL verification for the localhost loopback (self-signed dev certificate).
if (-not ([System.Management.Automation.PSTypeName]'BcTestSslVerification').Type) {
    Add-Type -TypeDefinition @"
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
public static class BcTestSslVerification {
    public static void Disable() { ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; }
    public static void Enable() { ServicePointManager.ServerCertificateValidationCallback = null; }
}
"@

}
[BcTestSslVerification]::Disable()

$junitDoc = $null
$junitRoot = $null
if ($JUnitResultFileName) {
    $junitDoc = New-BcJUnitDocument -Path $JUnitResultFileName
    $junitRoot = $junitDoc.DocumentElement
}
$xunitDoc = $null
$xunitRoot = $null
if ($XUnitResultFileName) {
    $xunitDoc = New-BcXUnitDocument -Path $XUnitResultFileName
    $xunitRoot = $xunitDoc.DocumentElement
}

$hostName = [System.Net.Dns]::GetHostName()
$allPassed = $true

Write-Host "Connecting to $serviceUrl ($Auth)"
$clientContext = $null
try {
    if ($Auth -eq 'AAD') {
        if (-not $AccessToken) { throw 'AAD authentication requires an access token.' }
        $clientContext = [BcTestClientContext]::new($serviceUrl, $AccessToken, $interactionTimeout, $Culture, $Timezone)
    }
    elseif ($Auth -eq 'Windows') {
        $clientContext = [BcTestClientContext]::new($serviceUrl, $interactionTimeout, $Culture, $Timezone)
    }
    else {
        if (-not $UserName) { throw 'NavUserPassword authentication requires a user name and password.' }
        $securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential -ArgumentList $UserName, $securePassword
        $clientContext = [BcTestClientContext]::new($serviceUrl, $credential, $interactionTimeout, $Culture, $Timezone)
    }
    $clientContext.debugMode = $DebugMode.IsPresent

    foreach ($app in @($TestApps)) {
        $extensionId = "$($app.ExtensionId)"
        $appName = "$($app.AppName)"
        $testRunnerCodeunitId = "$($app.TestRunnerCodeunitId)"
        $appSuite = if (($app.PSObject.Properties.Name -contains 'TestSuite') -and "$($app.TestSuite)") { "$($app.TestSuite)" } else { $TestSuite }

        Write-Host "Running tests for extension $extensionId$(if ($appName) { " ($appName)" }) [suite $appSuite]"

        $form = $clientContext.OpenForm($TestPage)
        if (-not $form) {
            throw "Cannot open test page $TestPage. Ensure the test toolkit is imported and the company/URL are correct."
        }

        $suiteControl = $clientContext.GetControlByName($form, 'CurrentSuiteName')
        $clientContext.SaveValue($suiteControl, $appSuite)

        $extensionIdControl = $clientContext.GetControlByName($form, 'ExtensionId')
        $clientContext.SaveValue($extensionIdControl, $extensionId)

        if ($testRunnerCodeunitId) {
            $runnerControl = $clientContext.GetControlByName($form, 'TestRunnerCodeunitId')
            if ($runnerControl) { $clientContext.SaveValue($runnerControl, $testRunnerCodeunitId) }
        }

        $clientContext.InvokeAction($clientContext.GetActionByName($form, 'ClearTestResults'))

        while ($true) {
            $clientContext.InvokeAction($clientContext.GetActionByName($form, 'RunNextTest'))
            $resultControl = $clientContext.GetControlByName($form, 'TestResultJson')
            $resultJson = $resultControl.StringValue
            if ($resultJson -eq 'All tests executed.' -or [string]::IsNullOrEmpty($resultJson)) { break }

            $result = $resultJson | ConvertFrom-Json
            $hasTestResults = [bool]($result.PSObject.Properties.Name -eq 'testResults')
            $totalTests = if ($hasTestResults) { @($result.testResults).Count } else { 0 }
            Write-Host -NoNewline " Codeunit $($result.codeUnit) $($result.name) "

            $passed = 0; $failed = 0; $skipped = 0
            $totalDuration = [timespan]::Zero

            $junitSuite = $null
            if ($junitDoc) {
                $junitSuite = $junitDoc.CreateElement('testsuite')
                $junitSuite.SetAttribute('name', "$($result.codeUnit) $($result.name)")
                $junitSuite.SetAttribute('timestamp', (Get-Date -Format s))
                $junitSuite.SetAttribute('hostname', $hostName)
                $junitSuite.SetAttribute('tests', $totalTests)
                $properties = $junitDoc.CreateElement('properties')
                $junitSuite.AppendChild($properties) | Out-Null
                if ($extensionId) {
                    $property = $junitDoc.CreateElement('property')
                    $property.SetAttribute('name', 'extensionid')
                    $property.SetAttribute('value', $extensionId)
                    $properties.AppendChild($property) | Out-Null
                }
                if ($appName) {
                    $property = $junitDoc.CreateElement('property')
                    $property.SetAttribute('name', 'appName')
                    $property.SetAttribute('value', $appName)
                    $properties.AppendChild($property) | Out-Null
                }
            }

            $xunitAssembly = $null
            $xunitCollection = $null
            if ($xunitDoc) {
                $xunitAssembly = $xunitDoc.CreateElement('assembly')
                $xunitAssembly.SetAttribute('name', "$($result.codeUnit) $($result.name)")
                $xunitAssembly.SetAttribute('test-framework', 'ALbuild Test Runner')
                $xunitAssembly.SetAttribute('run-date', (Get-BcDateTime -Value $result.startTime).ToString('yyyy-MM-dd'))
                $xunitAssembly.SetAttribute('run-time', (Get-BcDateTime -Value $result.startTime).ToString("HH':'mm':'ss"))
                $xunitAssembly.SetAttribute('total', $totalTests)
                $xunitCollection = $xunitDoc.CreateElement('collection')
                $xunitCollection.SetAttribute('name', $result.name)
                $xunitCollection.SetAttribute('total', $totalTests)
                $xunitAssembly.AppendChild($xunitCollection) | Out-Null
            }

            if ($hasTestResults) {
                foreach ($test in $result.testResults) {
                    $duration = (Get-BcDateTime -Value $test.finishTime).Subtract((Get-BcDateTime -Value $test.startTime))
                    if ($duration.TotalSeconds -lt 0) { $duration = [timespan]::Zero }
                    $totalDuration += $duration
                    $timeText = [Math]::Round($duration.TotalSeconds, 3).ToString([System.Globalization.CultureInfo]::InvariantCulture)

                    $junitCase = $null
                    if ($junitDoc) {
                        $junitCase = $junitDoc.CreateElement('testcase')
                        $junitCase.SetAttribute('classname', "$($result.codeUnit) $($result.name)")
                        $junitCase.SetAttribute('name', $test.method)
                        $junitCase.SetAttribute('time', $timeText)
                        $junitSuite.AppendChild($junitCase) | Out-Null
                    }
                    $xunitTest = $null
                    if ($xunitDoc) {
                        $xunitTest = $xunitDoc.CreateElement('test')
                        $xunitTest.SetAttribute('name', "$($result.name):$($test.method)")
                        $xunitTest.SetAttribute('method', $test.method)
                        $xunitTest.SetAttribute('time', $timeText)
                        $xunitCollection.AppendChild($xunitTest) | Out-Null
                    }

                    if ($test.result -eq 2) {
                        $passed++
                        if ($xunitTest) { $xunitTest.SetAttribute('result', 'Pass') }
                    }
                    elseif ($test.result -eq 1) {
                        $failed++
                        $allPassed = $false
                        $stackTraceText = "$($test.stackTrace)"
                        if ($stackTraceText.EndsWith(';')) { $stackTraceText = $stackTraceText.Substring(0, $stackTraceText.Length - 1) }
                        if ($AzureDevOps -ne 'no') {
                            Write-Host "##vso[task.logissue type=$AzureDevOps;sourcepath=$($test.method);]$($test.message)"
                        }
                        if ($junitCase) {
                            $junitFailure = $junitDoc.CreateElement('failure')
                            $junitFailure.SetAttribute('message', "$($test.message)")
                            $junitFailure.InnerText = $stackTraceText.Replace(';', "`n")
                            $junitCase.AppendChild($junitFailure) | Out-Null
                        }
                        if ($xunitTest) {
                            $xunitTest.SetAttribute('result', 'Fail')
                            $xunitFailure = $xunitDoc.CreateElement('failure')
                            $xunitMessage = $xunitDoc.CreateElement('message')
                            $xunitMessage.InnerText = "$($test.message)"
                            $xunitFailure.AppendChild($xunitMessage) | Out-Null
                            $xunitStack = $xunitDoc.CreateElement('stack-trace')
                            $xunitStack.InnerText = $stackTraceText.Replace(';', "`n")
                            $xunitFailure.AppendChild($xunitStack) | Out-Null
                            $xunitTest.AppendChild($xunitFailure) | Out-Null
                        }
                    }
                    else {
                        $skipped++
                        if ($junitCase) { $junitCase.AppendChild($junitDoc.CreateElement('skipped')) | Out-Null }
                        if ($xunitTest) { $xunitTest.SetAttribute('result', 'Skip') }
                    }
                }
            }

            $durationText = [Math]::Round($totalDuration.TotalSeconds, 3).ToString([System.Globalization.CultureInfo]::InvariantCulture)
            if ($result.result -eq 2) { Write-Host -ForegroundColor Green "Success ($durationText seconds)" }
            elseif ($result.result -eq 1) { Write-Host -ForegroundColor Red "Failure ($durationText seconds)" }
            else { Write-Host -ForegroundColor Yellow 'Skipped' }

            if ($junitSuite) {
                $junitSuite.SetAttribute('errors', 0)
                $junitSuite.SetAttribute('failures', $failed)
                $junitSuite.SetAttribute('skipped', $skipped)
                $junitSuite.SetAttribute('time', $durationText)
                $junitRoot.AppendChild($junitSuite) | Out-Null
            }
            if ($xunitAssembly) {
                $xunitAssembly.SetAttribute('passed', $passed)
                $xunitAssembly.SetAttribute('failed', $failed)
                $xunitAssembly.SetAttribute('skipped', $skipped)
                $xunitAssembly.SetAttribute('time', $durationText)
                $xunitCollection.SetAttribute('passed', $passed)
                $xunitCollection.SetAttribute('failed', $failed)
                $xunitCollection.SetAttribute('skipped', $skipped)
                $xunitCollection.SetAttribute('time', $durationText)
                $xunitRoot.AppendChild($xunitAssembly) | Out-Null
            }
        }

        $clientContext.CloseForm($form)
    }
}
finally {
    [BcTestSslVerification]::Enable()
    if ($clientContext) { $clientContext.Dispose() }
}

if ($junitDoc) { $junitDoc.Save($JUnitResultFileName) }
if ($xunitDoc) { $xunitDoc.Save($XUnitResultFileName) }

# Emit the overall pass/fail flag as the final line of output for the host to read.
Write-Output "ALBUILD_TESTRUN_ALLPASSED=$($allPassed.ToString().ToLowerInvariant())"