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.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Register-ObjectEvent action blocks run in a separate scope and can only share the open session via $Global:.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '', Justification = 'Session disposal is best-effort; a session that is already torn down must not surface a new error.')]
[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 = @()
    [object] $clientSession = $null
    [string] $culture = 'en-US'
    [string] $timezone = ''
    [bool] $debugMode = $false
    [object] $addressUri = $null

    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)
    }

    [void] Initialize([string] $serviceUrl, [object] $authenticationScheme, [object] $credential, [timespan] $interactionTimeout, [string] $sessionCulture, [string] $sessionTimezone) {
        $uri = New-Object System.Uri -ArgumentList $serviceUrl
        $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
        }
        $this.OpenSession()
    }

    [void] OpenSession() {
        $Global:BcTestOpenContext = $this
        $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)

        $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName MessageToShow -Action {
                Write-Host "Message: $($EventArgs.Message)"
            })
        $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName CommunicationError -Action {
                Write-Host -ForegroundColor Red "CommunicationError: $($EventArgs.Exception.Message)"
            })
        $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName UnhandledException -Action {
                Write-Host -ForegroundColor Red "UnhandledException: $($EventArgs.Exception.Message)"
            })
        $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName InvalidCredentialsError -Action {
                Write-Host -ForegroundColor Red 'InvalidCredentialsError'
            })
        $this.events += @(Register-ObjectEvent -InputObject $this.clientSession -EventName DialogToShow -Action {
                $form = $EventArgs.DialogToShow
                $context = $Global:BcTestOpenContext
                if (-not $context) { return }
                $errorDialogId = '00000000-0000-0000-0800-0000836bd2d2'
                $warningDialogId = '00000000-0000-0000-0300-0000836bd2d2'
                $infoDialogId = '8da61efd-0002-0003-0507-0b0d1113171d'
                if ($form.ControlIdentifier -eq $errorDialogId) {
                    $errorControl = $form.ContainedControls | Where-Object { $_ -is [Microsoft.Dynamics.Framework.UI.Client.ClientStaticStringControl] } | Select-Object -First 1
                    $context.CloseForm($form)
                    throw "$($errorControl.StringValue)"
                }
                elseif ($form.ControlIdentifier -eq $warningDialogId) {
                    $warningControl = $form.ContainedControls | Where-Object { $_ -is [Microsoft.Dynamics.Framework.UI.Client.ClientStaticStringControl] } | Select-Object -First 1
                    Write-Host -ForegroundColor Yellow "Warning: $($warningControl.StringValue)"
                    $context.CloseForm($form)
                }
                elseif ($form.ControlIdentifier -eq $infoDialogId) {
                    $context.CloseForm($form)
                }
                else {
                    $okAction = $context.GetActionByName($form, 'OK')
                    if ($okAction) { $context.InvokeAction($okAction) } else { $context.CloseForm($form) }
                }
            })

        $this.clientSession.OpenSessionAsync($parameters)
        $this.AwaitState([Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Ready)
    }

    [void] AwaitState([object] $state) {
        $start = [DateTime]::Now
        while ($this.clientSession.State -ne $state) {
            Start-Sleep -Milliseconds 100
            $current = $this.clientSession.State
            if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::InError) {
                throw "ClientSession entered the InError state (waited $(([DateTime]::Now - $start).TotalSeconds) seconds)."
            }
            if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::TimedOut) {
                throw "ClientSession entered the TimedOut state (waited $(([DateTime]::Now - $start).TotalSeconds) seconds)."
            }
            if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Uninitialized -and ([DateTime]::Now - $start).TotalSeconds -ge 120) {
                throw "ClientSession remained Uninitialized for $(([DateTime]::Now - $start).TotalSeconds) seconds."
            }
        }
        Start-Sleep -Milliseconds 100
    }

    [void] Dispose() {
        $Global:BcTestOpenContext = $null
        $this.events | ForEach-Object { Unregister-Event -SourceIdentifier $_.Name -ErrorAction SilentlyContinue }
        $this.events = @()
        try {
            if ($this.clientSession -and $this.clientSession.State -ne [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Closed) {
                $this.clientSession.CloseSessionAsync()
                $this.AwaitState([Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::Closed)
            }
        }
        catch {
            # The session may already be torn down; disposal is best-effort.
        }
    }

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

    [object] InvokeInteractionAndCatchForm([object] $interaction) {
        $Global:BcTestCaughtForm = $null
        $formEvent = Register-ObjectEvent -InputObject $this.clientSession -EventName FormToShow -Action {
            $Global:BcTestCaughtForm = $EventArgs.FormToShow
        }
        try {
            $this.InvokeInteraction($interaction)
            if (-not $Global:BcTestCaughtForm) { $this.CloseAllWarningForms() }
        }
        finally {
            Unregister-Event -SourceIdentifier $formEvent.Name -ErrorAction SilentlyContinue
        }
        $form = $Global:BcTestCaughtForm
        Remove-Variable -Name BcTestCaughtForm -Scope Global -ErrorAction SilentlyContinue
        return $form
    }

    [object] OpenForm([int] $page) {
        try {
            $interaction = New-Object Microsoft.Dynamics.Framework.UI.Client.Interactions.OpenFormInteraction
            $interaction.Page = $page
            return $this.InvokeInteractionAndCatchForm($interaction)
        }
        catch {
            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))
    }
}