Modules/businessdev.ALbuild.Apps/Resources/TestRunner/BcTestClientContext.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    In-container client-services session used to drive the AL Test Tool page.
 
.DESCRIPTION
    This is an ALbuild payload script: it runs *inside* the Business Central Windows container
    only. It is a clean, from-scratch reimplementation of the client-services session that the
    test runner uses to open the AL Test Tool page (130455), set its controls and invoke its
    actions through Microsoft.Dynamics.Framework.UI.Client. Those types exist only inside the BC
    container, so this file is never dot-sourced on the host (it lives under Resources/ and is not
    part of the module loader). The Microsoft.* types are referenced only inside method bodies so
    the class can be defined before the client DLL is fully resolved.
 
    Only the surface needed by the runner is implemented (open/close forms, read/write controls,
    invoke actions, dismiss dialogs) - deliberately smaller than the legacy reference.
 
    Session events (dialogs, transport errors, server messages) are NOT handled in
    Register-ObjectEvent -Action blocks. Those run outside the main pipeline: an exception thrown
    there never reaches the caller, output can arrive out of order with the wait loop's, and calling
    back into the session from them re-enters a session that is still processing an interaction.
    Instead every event is subscribed WITHOUT -Action so it queues, and the wait loop drains the
    queue on the main thread (DrainEvents). That way an error dialog raised while the session is
    opening becomes the thrown message instead of being lost - it is the only place where the server
    states why it refused to open a session.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUsePSCredentialType', '', Justification = 'Constructor overloads model the client-services auth schemes (credential, token and Windows) explicitly.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'Initialize accepts an [object] credential (NetworkCredential, TokenCredential or null) as required by the client-services API; no plaintext password is handled here.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'ClientDllPath documents the load contract; callers add-type the DLL before dot-sourcing this file.')]
param(
    [Parameter(Mandatory)] [string] $ClientDllPath
)

# Reference the parameter so analyzers see it as used; callers load the DLL before dot-sourcing.
$null = $ClientDllPath

class BcTestClientContext {
    [object[]] $events = @()
    # The source identifiers of the session-event subscriptions. DrainEvents matches on exactly these,
    # never on the shared prefix: InvokeInteractionAndCatchForm subscribes to FormToShow under the same
    # prefix and reads that event itself, so a prefix match would consume the caught form before it can.
    [string[]] $eventIdentifiers = @()
    [object] $clientSession = $null
    [string] $culture = 'en-US'
    [string] $timezone = ''
    [bool] $debugMode = $false
    [object] $addressUri = $null
    [string] $serviceUrl = ''
    # Prefix for this instance's event subscriptions, so DrainEvents only ever touches its own events
    # and a session recreated by a connect retry cannot inherit the previous session's queue.
    [string] $sourcePrefix = ''

    # --- Connect-wait tuning ---------------------------------------------------------------------
    # A session that is NOT connecting sits in 'Uninitialized'; give up on THIS attempt after
    # openTimeoutSeconds so the runner's connect-retry can recreate it quickly (the transient
    # CommunicationError case). A session that IS connecting/working sits in 'Busy' - that is normal
    # and is never fast-failed at seconds-scale, because a first connect on a cold container that
    # just installed a heavy ISV app stack legitimately takes minutes.
    [int] $openTimeoutSeconds = 30
    # Per-attempt cap for a session that stays Busy. This has to stay well BELOW the runner's total
    # connect budget: with a cap as large as the budget only one or two attempts ever run, so the
    # candidate URLs never get alternated and a URL-specific failure is indistinguishable from a
    # server that never answers.
    [int] $openReadyTimeoutSeconds = 240

    # --- Session-open observability --------------------------------------------------------------
    # Callback invoked once, ON THE MAIN THREAD, when an open has been Busy for slowOpenAfterSeconds.
    # The runner uses it to dump server-side state (BC sessions, active SQL, the IIS request for the
    # client-services endpoint) WHILE the open is still hanging - after the failure that state is gone.
    [scriptblock] $slowOpenCallback = $null
    [int] $slowOpenAfterSeconds = 0
    [bool] $slowOpenFired = $false

    # True only while the session is being opened. An error dialog raised during the open is fatal
    # (it is the server's reason for refusing the session); the same dialog during a test run is
    # reported and dismissed, because aborting there would discard the results collected so far.
    [bool] $connecting = $false
    [string] $fatalError = ''
    [string] $lastTransportError = ''
    [object] $pendingDialogs = $null
    [bool] $flushing = $false

    BcTestClientContext([string] $serviceUrl, [pscredential] $credential, [timespan] $interactionTimeout, [string] $culture, [string] $timezone) {
        $networkCredential = New-Object System.Net.NetworkCredential -ArgumentList $credential.UserName, $credential.Password
        $this.Initialize($serviceUrl, [Microsoft.Dynamics.Framework.UI.Client.AuthenticationScheme]::UserNamePassword, $networkCredential, $interactionTimeout, $culture, $timezone)
    }

    BcTestClientContext([string] $serviceUrl, [string] $accessToken, [timespan] $interactionTimeout, [string] $culture, [string] $timezone) {
        $tokenCredential = New-Object Microsoft.Dynamics.Framework.UI.Client.TokenCredential -ArgumentList $accessToken
        $this.Initialize($serviceUrl, [Microsoft.Dynamics.Framework.UI.Client.AuthenticationScheme]::AzureActiveDirectory, $tokenCredential, $interactionTimeout, $culture, $timezone)
    }

    BcTestClientContext([string] $serviceUrl, [timespan] $interactionTimeout, [string] $culture, [string] $timezone) {
        $this.Initialize($serviceUrl, [Microsoft.Dynamics.Framework.UI.Client.AuthenticationScheme]::Windows, $null, $interactionTimeout, $culture, $timezone)
    }

    # Builds the session but does NOT open it, so the caller can set the connect tuning and the
    # slow-open callback before calling OpenSession(). The HTTP timeout is the INTERACTION timeout
    # and cannot be lowered for the open alone (HttpClient forbids changing Timeout once a request
    # has been sent), which is exactly why the open needs its own wall-clock bound in AwaitState.
    [void] Initialize([string] $url, [object] $authenticationScheme, [object] $credential, [timespan] $interactionTimeout, [string] $sessionCulture, [string] $sessionTimezone) {
        $this.serviceUrl = $url
        $this.pendingDialogs = New-Object System.Collections.ArrayList
        $this.sourcePrefix = "BcTestSession-$([Guid]::NewGuid().ToString('N'))-"
        $uri = New-Object System.Uri -ArgumentList $url
        $this.addressUri = [Microsoft.Dynamics.Framework.UI.Client.ServiceAddressProvider]::ServiceAddress($uri)
        $jsonClient = New-Object Microsoft.Dynamics.Framework.UI.Client.JsonHttpClient -ArgumentList $this.addressUri, $credential, $authenticationScheme
        $httpClientField = $jsonClient.GetType().GetField('httpClient', [Reflection.BindingFlags]::NonPublic -bor [Reflection.BindingFlags]::Instance)
        $httpClient = $httpClientField.GetValue($jsonClient)
        $httpClient.Timeout = $interactionTimeout
        $this.clientSession = New-Object Microsoft.Dynamics.Framework.UI.Client.ClientSession -ArgumentList $jsonClient, (New-Object Microsoft.Dynamics.Framework.UI.Client.NonDispatcher), (New-Object 'Microsoft.Dynamics.Framework.UI.Client.TimerFactory[Microsoft.Dynamics.Framework.UI.Client.TaskTimer]')
        $this.culture = $sessionCulture
        if ([string]::IsNullOrEmpty($sessionTimezone)) {
            $tz = Get-TimeZone
            $match = Get-TimeZone -ListAvailable | Where-Object { $_.BaseUtcOffset -eq $tz.BaseUtcOffset -and $_.SupportsDaylightSavingTime -eq $tz.SupportsDaylightSavingTime } | Select-Object -First 1
            if ($match) { $this.timezone = $match.Id }
        }
        else {
            $this.timezone = $sessionTimezone
        }
    }

    [void] SetSlowOpenCallback([scriptblock] $callback, [int] $afterSeconds) {
        $this.slowOpenCallback = $callback
        $this.slowOpenAfterSeconds = $afterSeconds
    }

    # Subscribes to the session events WITHOUT -Action: the events queue and are drained on the main
    # thread by DrainEvents. Best-effort per event name so an event missing from an older client
    # assembly cannot stop the session from opening.
    [void] SubscribeEvents() {
        foreach ($eventName in @('MessageToShow', 'CommunicationError', 'UnhandledException', 'InvalidCredentialsError', 'DialogToShow')) {
            $identifier = "$($this.sourcePrefix)$eventName"
            try {
                $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName $eventName -SourceIdentifier $identifier)
                # Track the identifier we passed in: a subscription registered with an explicit
                # -SourceIdentifier does not carry it on the returned job's Name.
                $this.eventIdentifiers += $identifier
            }
            catch {
                Write-Host -ForegroundColor Yellow " [client session] could not subscribe to '$eventName': $($_.Exception.Message)"
            }
        }
    }

    [void] OpenSession() {
        $this.SubscribeEvents()
        $parameters = New-Object Microsoft.Dynamics.Framework.UI.Client.ClientSessionParameters
        $parameters.CultureId = $this.culture
        $parameters.UICultureId = $this.culture
        $parameters.TimeZoneId = $this.timezone
        $parameters.AdditionalSettings.Add('IncludeControlIdentifier', $true)

        # The parameters actually sent are part of the diagnosis when a server refuses or stalls an
        # open: a culture or time zone the instance does not accept looks exactly like a hang.
        Write-Host " [client session] opening: url '$($this.serviceUrl)', culture '$($this.culture)', time zone '$($this.timezone)'"

        $this.connecting = $true
        try {
            $this.clientSession.OpenSessionAsync($parameters)
            $this.AwaitState([Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Ready, $this.openTimeoutSeconds, $this.openReadyTimeoutSeconds)
        }
        finally {
            $this.connecting = $false
        }
        $this.FlushPendingDialogs()
    }

    # --- Event draining --------------------------------------------------------------------------
    # Runs on the main thread from inside the wait loops. Reports every event as it is observed and
    # records - never acts on - anything that would need an interaction, so the session is not
    # re-entered while it is still processing one.
    [void] DrainEvents() {
        if ($this.eventIdentifiers.Count -eq 0) { return }
        $mine = $this.eventIdentifiers
        $queued = @(Get-Event -ErrorAction SilentlyContinue | Where-Object { $mine -contains "$($_.SourceIdentifier)" })
        foreach ($evt in $queued) {
            try { $this.HandleSessionEvent("$($evt.SourceIdentifier)", $evt.SourceEventArgs) }
            catch { Write-Host -ForegroundColor Yellow " [client session] failed to handle event '$($evt.SourceIdentifier)': $($_.Exception.Message)" }
            Remove-Event -EventIdentifier $evt.EventIdentifier -ErrorAction SilentlyContinue
        }
    }

    [void] HandleSessionEvent([string] $sourceIdentifier, [object] $sessionEventArgs) {
        $kind = $sourceIdentifier.Substring($this.sourcePrefix.Length)
        if ($kind -eq 'MessageToShow') {
            Write-Host " [client session] server message: $($sessionEventArgs.Message)"
            return
        }
        if ($kind -eq 'CommunicationError') {
            $this.lastTransportError = "$($sessionEventArgs.Exception.Message)"
            Write-Host -ForegroundColor Red " [client session] communication error: $($this.lastTransportError)"
            return
        }
        if ($kind -eq 'UnhandledException') {
            $this.lastTransportError = "$($sessionEventArgs.Exception.Message)"
            Write-Host -ForegroundColor Red " [client session] unhandled exception: $($this.lastTransportError)"
            return
        }
        if ($kind -eq 'InvalidCredentialsError') {
            $this.fatalError = 'the service rejected the credentials (InvalidCredentialsError).'
            Write-Host -ForegroundColor Red " [client session] $($this.fatalError)"
            return
        }
        if ($kind -eq 'DialogToShow') {
            $this.HandleDialog($sessionEventArgs.DialogToShow)
            return
        }
        Write-Host " [client session] event '$kind'"
    }

    [void] HandleDialog([object] $form) {
        if (-not $form) { return }
        $errorDialogId = '00000000-0000-0000-0800-0000836bd2d2'
        $warningDialogId = '00000000-0000-0000-0300-0000836bd2d2'
        $infoDialogId = '8da61efd-0002-0003-0507-0b0d1113171d'
        $text = $this.GetDialogText($form)
        if ("$($form.ControlIdentifier)" -eq $errorDialogId) {
            # The one place the server explains why it will not hand out a session. Losing this text
            # (the previous implementation threw it from an -Action block, where the exception went
            # nowhere) turns a precise server error into an unexplained connect timeout.
            Write-Host -ForegroundColor Red " [client session] error dialog: $text"
            if ($this.connecting) { $this.fatalError = $text }
        }
        elseif ("$($form.ControlIdentifier)" -eq $warningDialogId) {
            Write-Host -ForegroundColor Yellow " [client session] warning dialog: $text"
        }
        elseif ("$($form.ControlIdentifier)" -eq $infoDialogId) {
            Write-Host " [client session] information dialog: $text"
        }
        else {
            Write-Host " [client session] dialog '$($form.Caption)' (control $($form.ControlIdentifier)): $text"
        }
        [void]$this.pendingDialogs.Add($form)
    }

    [string] GetDialogText([object] $form) {
        $parts = @()
        try {
            $parts = @($form.ContainedControls |
                    Where-Object { $_ -is [Microsoft.Dynamics.Framework.UI.Client.ClientStaticStringControl] } |
                    ForEach-Object { "$($_.StringValue)" } |
                    Where-Object { $_.Trim() })
        }
        catch { $parts = @() }
        if ($parts.Count -eq 0) { $parts = @("$($form.Caption)") }
        $text = ($parts -join ' ').Trim()
        if ($text.Length -gt 1000) { $text = $text.Substring(0, 1000) + '...' }
        return $text
    }

    # Closes the dialogs recorded while waiting. Called only from a point where the session has just
    # reached Ready, never from inside event handling, and never re-entrantly.
    [void] FlushPendingDialogs() {
        if ($this.flushing) { return }
        if ($this.pendingDialogs.Count -eq 0) { return }
        $this.flushing = $true
        try {
            $forms = @($this.pendingDialogs.ToArray())
            $this.pendingDialogs.Clear()
            foreach ($form in $forms) {
                try {
                    $okAction = $this.GetActionByName($form, 'OK')
                    if ($okAction) { $this.InvokeAction($okAction) } else { $this.CloseForm($form) }
                }
                catch {
                    # The dialog may already have gone away with the interaction that raised it.
                    Write-Host -ForegroundColor Yellow " [client session] could not dismiss dialog '$($form.Caption)': $($_.Exception.Message)"
                }
            }
        }
        finally {
            $this.flushing = $false
        }
    }

    # Wait for a target state while DRIVING INTERACTIONS - no time bound, because a real test can legitimately
    # run for a long time. InError/TimedOut still abort.
    [void] AwaitState([object] $state) {
        $this.AwaitState($state, 0, 0)
    }

    # Bounded wait for the CONNECT and DISPOSE. A session that is actively connecting/working sits in
    # 'Busy' - that is NORMAL and must not be fast-failed, or a slow first connect on a cold container
    # gets killed on every attempt. We therefore fast-fail only when the session is NOT connecting
    # (Uninitialized, after $uninitializedTimeoutSeconds) so the connect-retry can recreate it, and
    # apply $readyCapSeconds as a per-attempt cap. InError/TimedOut and a server error dialog abort.
    [void] AwaitState([object] $state, [int] $uninitializedTimeoutSeconds, [int] $readyCapSeconds) {
        $start = [DateTime]::Now
        $lastBeat = 0.0
        while ($this.clientSession.State -ne $state) {
            Start-Sleep -Milliseconds 100
            # Report and record what the session told us (dialogs, transport errors, messages) before
            # deciding whether to keep waiting - after an error dialog, waiting is pointless.
            $this.DrainEvents()
            $current = $this.clientSession.State
            $elapsed = ([DateTime]::Now - $start).TotalSeconds
            # Heartbeat so a slow open/interaction shows its state trajectory (Busy vs stuck) instead of
            # looking hung; also the evidence for whether a failing open is progressing or wedged.
            if (($elapsed - $lastBeat) -ge 30) {
                Write-Host " [client session] waiting for '$state': current state '$current', $([int]$elapsed)s elapsed"
                $lastBeat = $elapsed
            }
            # Capture server-side state WHILE the open is stuck; once it has failed, that state is gone.
            if ($this.connecting -and -not $this.slowOpenFired -and $this.slowOpenCallback -and $this.slowOpenAfterSeconds -gt 0 -and $elapsed -ge $this.slowOpenAfterSeconds) {
                $this.slowOpenFired = $true
                try { & $this.slowOpenCallback ([int]$elapsed) }
                catch { Write-Host -ForegroundColor Yellow " [client session] slow-open diagnostics failed: $($_.Exception.Message)" }
            }
            if ($this.fatalError) {
                $reason = $this.fatalError
                $this.fatalError = ''
                throw "Business Central refused the session: $reason"
            }
            if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::InError) {
                throw "ClientSession entered the InError state (waited $([int]$elapsed) seconds).$($this.TransportSuffix())"
            }
            if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::TimedOut) {
                throw "ClientSession entered the TimedOut state (waited $([int]$elapsed) seconds).$($this.TransportSuffix())"
            }
            if ($uninitializedTimeoutSeconds -gt 0 -and $current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Uninitialized -and $elapsed -ge $uninitializedTimeoutSeconds) {
                throw "ClientSession stayed Uninitialized for $([int]$elapsed) seconds (not connecting).$($this.TransportSuffix())"
            }
            if ($readyCapSeconds -gt 0 -and $elapsed -ge $readyCapSeconds) {
                throw "ClientSession did not reach '$state' within $readyCapSeconds seconds (current state: $current).$($this.TransportSuffix())"
            }
        }
        Start-Sleep -Milliseconds 100
        $this.DrainEvents()
    }

    [string] TransportSuffix() {
        if ($this.lastTransportError) { return " Last transport error: $($this.lastTransportError)" }
        return ''
    }

    [void] Dispose() {
        foreach ($identifier in @($this.eventIdentifiers)) {
            Unregister-Event -SourceIdentifier $identifier -ErrorAction SilentlyContinue
        }
        $this.events = @()
        $this.eventIdentifiers = @()
        # Drop this session's queued events so a session recreated by a retry starts clean.
        $prefix = $this.sourcePrefix
        @(Get-Event -ErrorAction SilentlyContinue | Where-Object { "$($_.SourceIdentifier)".StartsWith($prefix) }) |
            ForEach-Object { Remove-Event -EventIdentifier $_.EventIdentifier -ErrorAction SilentlyContinue }
        try {
            if ($this.clientSession -and $this.clientSession.State -ne [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Closed) {
                $this.clientSession.CloseSessionAsync()
                # Bounded so disposing a stuck session (e.g. between connect retries) cannot itself hang.
                $this.AwaitState([Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Closed, $this.openTimeoutSeconds, $this.openTimeoutSeconds)
            }
        }
        catch {
            # The session may already be torn down; disposal is best-effort.
            $null = $_
        }
    }

    [void] InvokeInteraction([object] $interaction) {
        $this.clientSession.InvokeInteractionAsync($interaction)
        $this.AwaitState([Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Ready)
        $this.FlushPendingDialogs()
    }

    [object] InvokeInteractionAndCatchForm([object] $interaction) {
        # Deliberately NOT added to $eventIdentifiers: this event is read here, not by DrainEvents.
        $formSource = "$($this.sourcePrefix)FormToShow"
        $caught = $null
        $null = Register-ObjectEvent -InputObject $this.clientSession -EventName FormToShow -SourceIdentifier $formSource
        try {
            $this.InvokeInteraction($interaction)
            $formEvents = @(Get-Event -ErrorAction SilentlyContinue | Where-Object { "$($_.SourceIdentifier)" -eq $formSource })
            if ($formEvents.Count -gt 0) {
                $caught = $formEvents[$formEvents.Count - 1].SourceEventArgs.FormToShow
            }
            if (-not $caught) { $this.CloseAllWarningForms() }
        }
        finally {
            Unregister-Event -SourceIdentifier $formSource -ErrorAction SilentlyContinue
            @(Get-Event -ErrorAction SilentlyContinue | Where-Object { "$($_.SourceIdentifier)" -eq $formSource }) |
                ForEach-Object { Remove-Event -EventIdentifier $_.EventIdentifier -ErrorAction SilentlyContinue }
        }
        return $caught
    }

    [object] OpenForm([int] $page) {
        try {
            $interaction = New-Object Microsoft.Dynamics.Framework.UI.Client.Interactions.OpenFormInteraction
            $interaction.Page = $page
            return $this.InvokeInteractionAndCatchForm($interaction)
        }
        catch {
            Write-Host -ForegroundColor Yellow " [client session] opening page $page failed: $($_.Exception.Message)"
            return $null
        }
    }

    [void] CloseForm([object] $form) {
        $this.InvokeInteraction((New-Object Microsoft.Dynamics.Framework.UI.Client.Interactions.CloseFormInteraction -ArgumentList $form))
    }

    [object[]] GetAllForms() {
        $forms = @()
        $this.clientSession.OpenedForms.GetEnumerator() | ForEach-Object { $forms += $_ }
        return $forms
    }

    [void] CloseAllForms() {
        $this.GetAllForms() | ForEach-Object { $this.CloseForm($_) }
    }

    [void] CloseAllWarningForms() {
        $warningDialogId = '00000000-0000-0000-0300-0000836bd2d2'
        $this.GetAllForms() | ForEach-Object {
            if ($_.ControlIdentifier -eq $warningDialogId) { $this.CloseForm($_) }
        }
    }

    [object] GetControlByName([object] $control, [string] $name) {
        $result = $control.ContainedControls | Where-Object { $_.Name -eq $name } | Select-Object -First 1
        if (-not $result) {
            $result = $control.ContainedControls | Where-Object { $_.Caption -eq $name } | Select-Object -First 1
        }
        return $result
    }

    [object] GetControlByType([object] $control, [Type] $type) {
        return $control.ContainedControls | Where-Object { $_ -is $type } | Select-Object -First 1
    }

    [object] GetActionByName([object] $control, [string] $name) {
        $result = $control.ContainedControls | Where-Object { ($_ -is [Microsoft.Dynamics.Framework.UI.Client.ClientActionControl]) -and ($_.Name -eq $name) } | Select-Object -First 1
        if (-not $result) {
            $result = $control.ContainedControls | Where-Object { ($_ -is [Microsoft.Dynamics.Framework.UI.Client.ClientActionControl]) -and ($_.Caption -eq $name) } | Select-Object -First 1
        }
        return $result
    }

    [void] SaveValue([object] $control, [object] $newValue) {
        $this.InvokeInteraction((New-Object Microsoft.Dynamics.Framework.UI.Client.Interactions.SaveValueInteraction -ArgumentList $control, $newValue))
    }

    [void] InvokeAction([object] $action) {
        $this.InvokeInteraction((New-Object Microsoft.Dynamics.Framework.UI.Client.Interactions.InvokeActionInteraction -ArgumentList $action))
    }
}