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