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, # Per-attempt cap for an open that stays 'Busy' (the server accepted the connection but never # finished opening the session). It MUST stay well below $ConnectBudgetSeconds: a cap as large as # the whole budget lets only one attempt run, so the candidate URLs are never alternated and a # URL-specific failure is indistinguishable from a server that never answers. [int] $OpenReadyTimeoutSeconds = 240, # Total wall-clock budget for getting a session, across all attempts. Bounds the step instead of # letting retries run into the pipeline job timeout. [int] $ConnectBudgetSeconds = 900, # Dump server-side state once an open has been 'Busy' this long, WHILE it is still stuck. After the # attempt fails the sessions and SQL requests that would explain it are gone. [int] $SlowOpenDiagnosticsSeconds = 90 ) $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. # --- Server-side evidence for a session-open that never completes ------------------------------ # When the client reports "the connection was accepted but the session never became Ready", the # client side is out of information: everything that explains it lives in the container. These # collectors answer, in order, the three questions that separate the possible causes: # 1. Does the SERVICE TIER even see a session? No session => the request never got past the web # client / IIS layer. A session in a running state => AL code on session open is what is slow. # 2. What is SQL doing? A long-running statement names the table (and therefore the app) that the # open is stuck on. # 3. What did IIS do with the POST to the client-services endpoint? Status and time-taken # distinguish "never reached the service tier" from "the service tier never answered". # Every collector is best-effort: a missing module, cmdlet or log file must never mask the failure # it is trying to explain. # BC session list. The management cmdlets live in the service folder; on containers that still ship # the Windows PowerShell layer they import from there (BC29 dropped it, hence the guarded import). function Get-BcSessionDiagnostics { $lines = @() try { if (-not (Get-Command -Name 'Get-NAVServerSession' -ErrorAction SilentlyContinue)) { $mgmt = @(Get-ChildItem -Path 'C:\Program Files\Microsoft Dynamics NAV' -Filter 'Microsoft.Dynamics.Nav.Management.dll' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1) if ($mgmt.Count -eq 0) { return 'BC sessions: the management module was not found in this container (BC29+ removed the Windows PowerShell layer).' } Import-Module -Name $mgmt[0].FullName -DisableNameChecking -ErrorAction Stop } $instances = @(Get-Service -Name 'MicrosoftDynamicsNavServer$*' -ErrorAction SilentlyContinue | ForEach-Object { ($_.Name -split '\$')[-1] }) if ($instances.Count -eq 0) { $instances = @('BC') } foreach ($instance in $instances) { $sessions = @(Get-NAVServerSession -ServerInstance $instance -ErrorAction Stop) if ($sessions.Count -eq 0) { # The decisive negative: the client is waiting on a session the service tier never got. $lines += "BC sessions on '$instance': NONE - the service tier has no session for this connect, so the request did not reach it (web client / IIS layer)." continue } $lines += "BC sessions on '$instance': $($sessions.Count)" $lines += @($sessions | Select-Object -First 15 | ForEach-Object { " id $($_.SessionId) $($_.ClientType) user '$($_.UserId)' state '$($_.State)' started $($_.LoginDatetime) (company '$($_.CompanyName)')" }) } } catch { $lines += "BC sessions: could not be read ($($_.Exception.Message))." } return ($lines -join [Environment]::NewLine) } # Active SQL requests. The container's admin account is added to sysadmin on the local SQLEXPRESS # instance when the container starts, so integrated security is enough. function Get-BcSqlDiagnostics { $query = @' SELECT TOP 15 r.session_id, r.status, r.command, r.wait_type, r.wait_time, r.total_elapsed_time, DB_NAME(r.database_id) AS db, SUBSTRING(t.text, 1, 400) AS stmt FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t WHERE r.session_id <> @@SPID AND r.session_id > 50 ORDER BY r.total_elapsed_time DESC '@ foreach ($server in @('localhost\SQLEXPRESS', 'localhost', '.\SQLEXPRESS')) { $connection = $null try { $connection = New-Object System.Data.SqlClient.SqlConnection "Server=$server;Database=master;Integrated Security=SSPI;Connect Timeout=10" $connection.Open() $command = $connection.CreateCommand() $command.CommandText = $query $command.CommandTimeout = 20 $reader = $command.ExecuteReader() $rows = @() while ($reader.Read()) { $statement = ("$($reader['stmt'])" -replace '\s+', ' ').Trim() $rows += " spid $($reader['session_id']) [$($reader['status'])] $($reader['command']) wait '$($reader['wait_type'])' elapsed $([int]$reader['total_elapsed_time']) ms db '$($reader['db'])': $statement" } $reader.Close() if ($rows.Count -eq 0) { return "Active SQL requests on '$server': none (the database is idle - nothing is running for this open)." } return (@("Active SQL requests on '$server' (longest first):") + $rows) -join [Environment]::NewLine } catch { $null = $_ } finally { if ($connection) { try { $connection.Dispose() } catch { $null = $_ } } } } return 'Active SQL requests: the local SQL instance could not be queried.' } # IIS access-log lines for the client-services endpoint. The w3c log records the request only once it # COMPLETES, so an in-flight open is visible as an absent entry - which is itself the answer. function Get-BcClientServicesRequestLog { try { $logs = @(Get-ChildItem -Path 'C:\inetpub\logs\LogFiles' -Filter '*.log' -Recurse -ErrorAction Stop | Sort-Object LastWriteTime -Descending | Select-Object -First 2) if ($logs.Count -eq 0) { return 'IIS log: no log files found under C:\inetpub\logs\LogFiles.' } $hits = @() foreach ($log in $logs) { $hits += @(Get-Content -Path $log.FullName -Tail 400 -ErrorAction Stop | Where-Object { $_ -match '/cs' } | Select-Object -Last 8) } if ($hits.Count -eq 0) { return 'IIS log: no completed request to the client-services endpoint (/cs) - the POST that opens the session has not finished.' } return (@('IIS log, last completed client-services requests (fields: date time ... uri status ... time-taken):') + @($hits | ForEach-Object { " $_" })) -join [Environment]::NewLine } catch { return "IIS log: could not be read ($($_.Exception.Message))." } } # 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) } $script:slowOpenReported = $false # Invoked by the client context from the wait loop, once, while an open is still stuck in 'Busy'. # This is the only moment the evidence exists: the session, its SQL work and the in-flight request # are all gone by the time the attempt fails. $slowOpenDiagnostics = { param([int] $elapsedSeconds) if ($script:slowOpenReported) { return } $script:slowOpenReported = $true Write-Host -ForegroundColor Yellow " The session open has been busy for $elapsedSeconds s - capturing server-side state while it is still stuck:" Write-Host (Get-BcSessionDiagnostics) Write-Host (Get-BcSqlDiagnostics) Write-Host (Get-BcClientServicesRequestLog) } $clientContext = $null $connectStart = [DateTime]::Now $lastConnectError = 'no attempt was made.' $attemptsMade = 0 for ($attempt = 1; $attempt -le $ConnectRetryCount; $attempt++) { $budgetLeft = $ConnectBudgetSeconds - [int](([DateTime]::Now - $connectStart).TotalSeconds) if ($budgetLeft -le 5) { Write-Host -ForegroundColor Yellow " Connect budget of $ConnectBudgetSeconds s is exhausted after $attemptsMade attempt(s); giving up." break } $attemptsMade = $attempt $serviceUrl = $serviceUrls[($attempt - 1) % $serviceUrls.Count] # Cap this attempt so the remaining budget still allows the OTHER candidate URL to be tried: a # per-attempt cap as large as the budget would spend everything on one URL. $readyCap = [Math]::Min($OpenReadyTimeoutSeconds, $budgetLeft) 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) } # The constructor only builds the session; tuning and the slow-open probe are set before the # open so both apply to the very first attempt (which is usually the informative one). $clientContext.openReadyTimeoutSeconds = $readyCap $clientContext.SetSlowOpenCallback($slowOpenDiagnostics, $SlowOpenDiagnosticsSeconds) Write-Host " Client session attempt $attempt/$ConnectRetryCount via $serviceUrl (up to $readyCap s, $budgetLeft s of budget left)." $clientContext.OpenSession() Write-Host " Connected via $serviceUrl." break } catch { $lastConnectError = "$($_.Exception.Message)" if ($clientContext) { try { $clientContext.Dispose() } catch { $null = $_ }; $clientContext = $null } # Every failure mode is retried on the NEXT candidate URL, bounded by $ConnectBudgetSeconds. # A 'Busy' timeout used to stop the loop outright, which hid the fact that the two candidate # URLs fail differently (the container host name and localhost do not take the same path # through the web client), and burned the whole step on a single URL. if ($attempt -ge $ConnectRetryCount) { break } Write-Host -ForegroundColor Yellow " Attempt $attempt/$ConnectRetryCount via $serviceUrl failed ($lastConnectError). Retrying in $ConnectRetrySeconds s..." Start-Sleep -Seconds $ConnectRetrySeconds } } if (-not $clientContext) { $elapsedConnect = [int](([DateTime]::Now - $connectStart).TotalSeconds) # If the open never got past 'Busy', say so in the words that name the layer at fault: the # endpoint accepted the connection, so the failure is behind it, and the diagnostics below (BC # sessions, SQL, IIS) say how far behind. $stalled = $lastConnectError -like "*did not reach 'Ready'*" $reason = if ($stalled) { 'the service accepted the connection but never finished opening the session' } else { 'the service tier did not accept a connection' } $message = "Could not open a client session after $attemptsMade attempt(s) in $elapsedConnect s against $($serviceUrls -join ' / ') - $reason. Last error: $lastConnectError" throw (@( $message '--- BC service diagnostics ---' (Get-BcServerDiagnostics) (Get-BcSessionDiagnostics) (Get-BcSqlDiagnostics) (Get-BcClientServicesRequestLog) ) -join [Environment]::NewLine) } $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())" |