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 # How long a freshly-opened session may sit Uninitialized before we give up on THIS attempt. Kept # short (the BcContainerHelper approach) so the runner's connect-retry can recreate the session and try # again quickly, instead of one connect blocking for minutes on a transient CommunicationError. [int] $openTimeoutSeconds = 30 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, $this.openTimeoutSeconds) } # Wait for a target state with NO wall-clock bound - used while driving interactions, where a real test # can legitimately run for a long time (InError/TimedOut still abort). [void] AwaitState([object] $state) { $this.AwaitState($state, 0) } # Wall-clock-bounded wait (used for the connect and disposal). A connect that hangs - service tier not # accepting connections yet, TLS reset - sits in a PENDING state, NOT Uninitialized, so a state-specific # check never fires and a single failed connect could block for ~24 minutes until the underlying HTTP # request finally errored (then the DevOps job timeout cancels the task). $timeoutSeconds > 0 gives up # after that many seconds regardless of the current (non-target) state, so the runner's connect-retry # can dispose and try again quickly. [void] AwaitState([object] $state, [int] $timeoutSeconds) { $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 ($timeoutSeconds -gt 0 -and ([DateTime]::Now - $start).TotalSeconds -ge $timeoutSeconds) { throw "ClientSession did not reach '$state' within $timeoutSeconds seconds (current state: $current). The service tier may not be accepting connections yet." } } 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() # 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) } } 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)) } } |