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 # 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 (a transient # CommunicationError case). A session that IS connecting/working sits in 'Busy' - that is NORMAL and is # NEVER fast-failed (killing it murders a legitimately slow first connect on a cold container, which is # exactly what broke on the customer's new Server Core box; BcContainerHelper never bounds Busy either). # openReadyTimeoutSeconds is only a generous last-resort cap so a truly wedged session cannot hang until # the DevOps job timeout - a healthy client session opens in seconds, well under this. [int] $openTimeoutSeconds = 30 # Generous last-resort cap for a session that stays Busy (actively opening). On a container that just # installed a heavy ISV app stack, opening the first session (loading every installed app on session # start) can legitimately take many minutes - BcContainerHelper waits on Busy with no wall-clock bound # at all. 20 min lets a slow-but-progressing open finish while still bounding a truly wedged session # below the DevOps job timeout. [int] $openReadyTimeoutSeconds = 1200 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, $this.openReadyTimeoutSeconds) } # 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. Key point (this is where the from-scratch reimplementation # went wrong): 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 every attempt (the customer's # new Server Core box: state stayed 'Busy', never reached 'Ready' in 30 s, so all 10 retries failed). # BcContainerHelper never bounds Busy either. We therefore ONLY fast-fail when the session is NOT # connecting (Uninitialized, after $uninitializedTimeoutSeconds) so the connect-retry can recreate it, # and apply a GENEROUS overall cap ($readyCapSeconds) purely as a last resort so a truly wedged session # can never hang until the DevOps job timeout. InError/TimedOut always 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 $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 } if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::InError) { throw "ClientSession entered the InError state (waited $([int]$elapsed) seconds)." } if ($current -eq [Microsoft.Dynamics.Framework.UI.Client.ClientSessionState]::TimedOut) { throw "ClientSession entered the TimedOut state (waited $([int]$elapsed) seconds)." } 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)." } if ($readyCapSeconds -gt 0 -and $elapsed -ge $readyCapSeconds) { throw "ClientSession did not reach '$state' within $readyCapSeconds seconds (current state: $current)." } } 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, $this.openReadyTimeoutSeconds) } } 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)) } } |