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, [ValidateSet('Disabled', 'PerRun', 'PerCodeunit', 'PerTest')] [string] $CodeCoverageTrackingType = 'Disabled', [ValidateSet('Disabled', 'PerCodeunit', 'PerTest')] [string] $CodeCoverageMap = 'Disabled', [string] $CodeCoverageOutputPath = '', # Client-session connect resilience. The container reports "ready" before its client-services endpoint # is reliably reachable, so the first connect can hit a transient "CommunicationError" and never leave # Uninitialized. Recreate and retry the connect a few times with a short backoff before giving up. [int] $ConnectRetryCount = 10, [int] $ConnectRetrySeconds = 12 ) $ErrorActionPreference = 'Stop' function Get-BcClientDllPaths { $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." } # Newtonsoft.Json has to be the build that matches the client assembly and this (Windows PowerShell, # .NET Framework) host, so prefer the copy that ships NEXT TO the client DLL in C:\Test Assemblies. # Through BC28 the ...\Service\Management\ copy served that purpose, but BC29 deleted that whole folder # along with the Windows PowerShell 5 compatibility layer; the remaining ...\Service\ copy is the .NET 8 # build, and Add-Type on it fails with "Unable to load one or more of the requested types". The # Service paths stay as fallbacks for older layouts. $newtonSoftCandidates = @( (Join-Path (Split-Path -Path $clientDllPath -Parent) 'Newtonsoft.Json.dll'), "C:\Program Files\Microsoft Dynamics NAV\*\Service\Management\Newtonsoft.Json.dll", "C:\Program Files\Microsoft Dynamics NAV\*\Service\Newtonsoft.Json.dll" ) $newtonSoftDllPath = $null foreach ($candidate in $newtonSoftCandidates) { $hit = @(Get-Item -Path $candidate -ErrorAction SilentlyContinue) | Select-Object -First 1 if ($hit) { $newtonSoftDllPath = $hit.FullName; break } } if (-not $newtonSoftDllPath) { throw "Newtonsoft.Json.dll was not found. Searched: $($newtonSoftCandidates -join '; ')." } 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) # Connect to the client services at the container's OWN PublicWebBaseUrl host - do NOT rewrite it to # 'localhost'. The BC client-services endpoint validates/routes on the request Host header matching the # server's configured PublicWebBaseUrl; connecting via 'localhost' can leave the session stuck in the # 'Busy' state (the open request is accepted, but interaction responses never complete) on some # container/host setups - observed on Windows Server 2025 Core, where every connect attempt sat in Busy. # BcContainerHelper connects to the PublicWebBaseUrl host in-container for exactly this reason, and the # container's own hostname resolves to itself inside the container. SSL verification is disabled by the # caller, so the self-signed cert (issued for that host) is fine. $base = $PublicWebBaseUrl.TrimEnd('/') $serviceUrl = "$base/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 # Candidate client-services URLs, tried in order across the connect retries: # 1. The container's own PublicWebBaseUrl host (BcContainerHelper's choice; the correct Host header for # the client-services endpoint - connecting via 'localhost' can leave the session stuck 'Busy'). # 2. localhost, as a fallback for setups where the container host does not resolve in-container. # Whichever the container actually serves wins; alternating across attempts means one bad choice cannot # fail the whole run. $primaryUrl = Get-BcServiceUrl -PublicWebBaseUrl $server.PublicWebBaseUrl -Tenant $Tenant -CompanyName $CompanyName $pwbUri = [Uri]::new($server.PublicWebBaseUrl) $localhostBase = "$($pwbUri.Scheme)://localhost:$($pwbUri.Port)$($pwbUri.AbsolutePath.TrimEnd('/'))" $fallbackUrl = Get-BcServiceUrl -PublicWebBaseUrl $localhostBase -Tenant $Tenant -CompanyName $CompanyName $serviceUrls = @($primaryUrl) if ($fallbackUrl -ne $primaryUrl) { $serviceUrls += $fallbackUrl } $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 the client services ($Auth). Candidate URL(s): $($serviceUrls -join ', ')" # Open the client session with retries. The container's readiness marker fires before the client-services # endpoint is reliably reachable, so a single connect intermittently fails with "CommunicationError: An # error occurred while sending the request" and the session stays Uninitialized - which used to fail the # whole test run. Recreate the session and retry with a short backoff (fail fast on Uninitialized via the # context's openTimeoutSeconds, then retry here) - the BcContainerHelper resilience model. Each attempt # alternates through $serviceUrls so the container-host and localhost candidates are both exercised. # In-container diagnostics for a session-open failure: the BC Server event log (the real reason the # server could not finish opening the session), the service state, and free memory (opening a session # loads every installed app, so a heavy ISV stack can exhaust a too-small container). Best-effort. function Get-BcServerDiagnostics { $lines = @() $providers = @() try { $lines += @(Get-Service -Name 'MicrosoftDynamicsNavServer$*' -ErrorAction SilentlyContinue | ForEach-Object { # The Windows Application-log provider is named after the service instance # (e.g. 'MicrosoftDynamicsNavServer$BC') - capture it so we read the right one below. $providers += $_.Name "BC service $($_.Name): $($_.Status)" }) } catch { $null = $_ } try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $lines += ("Container memory: {0:N1} GB free of {1:N1} GB total" -f ($os.FreePhysicalMemory / 1MB), ($os.TotalVisibleMemorySize / 1MB)) } catch { $null = $_ } # BC Server does NOT create a dedicated 'Microsoft-DynamicsNAV-Server' event-log channel on a BC # container; it writes into the Windows *Application* log under a provider named after the service # instance ('MicrosoftDynamicsNavServer$<instance>'). Read recent error/warning events per provider # (a per-provider loop so an unregistered/empty provider can't blank out the rest). if (-not $providers) { $providers = @('MicrosoftDynamicsNavServer$BC') } $providers = @($providers) + @('MicrosoftDynamicsNAVClientClientService') | Select-Object -Unique $events = @() foreach ($provider in $providers) { try { $events += @(Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = $provider } -MaxEvents 25 -ErrorAction Stop) } catch { $null = $_ } } try { $ev = @($events | Where-Object { $_.LevelDisplayName -in @('Error', 'Warning', 'Critical') } | Sort-Object TimeCreated -Descending | Select-Object -First 15) if ($ev) { $lines += 'Recent BC Server error/warning events (newest first):' # A BC Server event message starts with a block of "Key: <guid/instance>" header lines # (Server instance, ClientSessionId, ServerActivityId, EventTime, ...); the actionable text # is below it ("Message (<ExceptionType>): ...", "RootException: ...", the human message). # Drop the noisy header keys so the real reason surfaces instead of "Server instance: BC". $headerKeys = '^(Server instance|ClientSessionId|ClientActivityId|ServerSessionUniqueId|ServerActivityId|EventTime|ClientComputerName|ClientAddress|UserName|CounterInformation|ProcessId|Tenant|AadTenantId)\s*:' $lines += @($ev | ForEach-Object { $body = @(($_.Message -split "`r?`n") | Where-Object { $_.Trim() -and $_ -notmatch $headerKeys } | Select-Object -First 4) $msg = ($body -join ' | ').Trim() if ($msg.Length -gt 600) { $msg = $msg.Substring(0, 600) + '...' } " [$($_.TimeCreated.ToString('HH:mm:ss'))] $($_.ProviderName) $($_.LevelDisplayName) (Id $($_.Id)): $msg" }) } else { $lines += "No recent BC Server error/warning events found in the Application log (providers: $($providers -join ', '))." } } catch { $lines += "Could not read the BC Server event log (Application / $($providers -join ', ')): $($_.Exception.Message)" } return ($lines -join [Environment]::NewLine) } $clientContext = $null for ($attempt = 1; $attempt -le $ConnectRetryCount; $attempt++) { $serviceUrl = $serviceUrls[($attempt - 1) % $serviceUrls.Count] 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) } Write-Host " Connected via $serviceUrl." break } catch { $errMsg = "$($_.Exception.Message)" if ($clientContext) { try { $clientContext.Dispose() } catch { $null = $_ }; $clientContext = $null } # A "did not reach Ready" (Busy) failure means the server ACCEPTED the connection but never finished # opening the session within the generous patience window - retrying just restarts the same # slow/wedged open, so stop now with server-side diagnostics instead of thrashing. A connect blip # (Uninitialized / CommunicationError) is worth retrying on the next candidate URL. $busyTimeout = $errMsg -like "*did not reach 'Ready'*" if ($busyTimeout -or $attempt -ge $ConnectRetryCount) { $reason = if ($busyTimeout) { "the service accepted the connection but did not finish opening the session ($errMsg)" } else { "after $ConnectRetryCount attempt(s) against $($serviceUrls -join ' / '): $errMsg" } throw ("Could not open a client session - $reason.$([Environment]::NewLine)--- BC service diagnostics ---$([Environment]::NewLine)$(Get-BcServerDiagnostics)") } Write-Host -ForegroundColor Yellow " Client session attempt $attempt/$ConnectRetryCount via $serviceUrl failed ($errMsg). Retrying in $ConnectRetrySeconds s..." Start-Sleep -Seconds $ConnectRetrySeconds } } $clientContext.debugMode = $DebugMode.IsPresent try { $ccCleared = $false 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) } } # Code coverage: set the AL Test Suite tracking type / map on the test page and clear once before # the first run. The test-runner app exposes these controls (CCTrackingType/CCMap) and actions. if ($CodeCoverageTrackingType -ne 'Disabled') { $ccTypeValues = @{ Disabled = 0; PerRun = 1; PerCodeunit = 2; PerTest = 3 } $ccMapValues = @{ Disabled = 0; PerCodeunit = 1; PerTest = 2 } $ccTypeControl = $clientContext.GetControlByName($form, 'CCTrackingType') if ($ccTypeControl) { $clientContext.SaveValue($ccTypeControl, $ccTypeValues[$CodeCoverageTrackingType]) } $ccMapControl = $clientContext.GetControlByName($form, 'CCMap') if ($ccMapControl) { $clientContext.SaveValue($ccMapControl, $ccMapValues[$CodeCoverageMap]) } if (-not $ccCleared) { $ccClearAction = $clientContext.GetActionByName($form, 'ClearCodeCoverage') if ($ccClearAction) { $clientContext.InvokeAction($ccClearAction) } $ccCleared = $true } } $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) } # Pull the accumulated code coverage out via the test page's GetCodeCoverage action: each call # returns one object's coverage CSV in CCResultsCSVText keyed by CCInfo; loop until CCInfo stops # advancing (the page returns empty / repeats when there is nothing left). if ($CodeCoverageTrackingType -ne 'Disabled' -and $CodeCoverageOutputPath) { Write-Host 'Collecting code coverage results...' if (Test-Path -LiteralPath $CodeCoverageOutputPath) { Get-ChildItem -LiteralPath $CodeCoverageOutputPath -Filter '*.dat' -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue } else { New-Item -ItemType Directory -Force -Path $CodeCoverageOutputPath | Out-Null } $covForm = $clientContext.OpenForm($TestPage) try { $getAction = $clientContext.GetActionByName($covForm, 'GetCodeCoverage') if (-not $getAction) { Write-Host 'WARNING: GetCodeCoverage action not found on the test page; code coverage is not supported by this test toolkit.' } else { $prevInfo = [guid]::NewGuid().ToString(); $iter = 0; $chunks = 0 do { $iter++ $clientContext.InvokeAction($clientContext.GetActionByName($covForm, 'GetCodeCoverage')) $ccResult = "$($clientContext.GetControlByName($covForm, 'CCResultsCSVText').StringValue)" $ccInfo = "$($clientContext.GetControlByName($covForm, 'CCInfo').StringValue)" # The page emits a 'Done.' marker as the terminal CCInfo - stop without saving it. if ([string]::IsNullOrEmpty($ccInfo) -or $ccInfo -eq $prevInfo -or $ccInfo -match '^Done') { break } $safeInfo = ($ccInfo -replace '[,\\/:*?"<>|\s]', '-') Set-Content -LiteralPath (Join-Path $CodeCoverageOutputPath "coverage_$safeInfo.dat") -Value $ccResult -Encoding UTF8 $prevInfo = $ccInfo; $chunks++ } while ($iter -lt 1000) Write-Host "Collected $chunks code coverage chunk(s) into $CodeCoverageOutputPath." } } finally { $clientContext.CloseForm($covForm) } } } 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())" |