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