1938 lines
84 KiB
PowerShell
1938 lines
84 KiB
PowerShell
param(
|
|
[string]$BackendApiBase = "http://192.168.200.61:8080/api/v1",
|
|
[string]$BackendWsBase = "ws://192.168.200.61:8080/api/v1/gateway/ws",
|
|
[string]$Email = "windows-smoke@example.local",
|
|
[string]$Password = "SmokePass!123",
|
|
[string]$DefaultResourceName = "Windows Smoke Default Resource",
|
|
[string]$AltOrganizationName = "Windows Smoke Alt Org",
|
|
[string]$AltResourceName = "Windows Smoke Alt Resource",
|
|
[string]$ClientExePath = "clients/windows/src/RemoteAccessPlatform.Windows.App/bin/Debug/net10.0-windows/RemoteAccessPlatform.Windows.App.exe",
|
|
[switch]$UseApiAuthBootstrap = $true,
|
|
[switch]$VerifyWorkerDeath = $false,
|
|
[switch]$VerifyClipboardServerToClient = $false,
|
|
[switch]$VerifyClipboardMatrix = $false,
|
|
[string]$DockerSshAlias = "docker-test",
|
|
[string]$RemoteDockerHost = "",
|
|
[string]$RemoteDockerUser = "",
|
|
[string]$RemoteDockerPassword = "",
|
|
[string]$WorkerContainerName = "rap_worker_smoke",
|
|
[bool]$PreferDirectDataPlane = $true,
|
|
[int]$DirectDataPlaneConnectTimeoutMs = 750,
|
|
[bool]$AllowInsecureDirectDataPlaneTlsForSmoke = $false,
|
|
[ValidateSet("full_color", "grayscale")]
|
|
[string]$DirectDataPlaneColorMode = "full_color",
|
|
[string]$DirectDataPlanePlatformCaBundle = "",
|
|
[string]$BackendEnvironment = "development",
|
|
[switch]$SkipOrgSwitchAndTokenRefresh = $false
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
Add-Type -AssemblyName UIAutomationClient
|
|
Add-Type -AssemblyName UIAutomationTypes
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
Add-Type @"
|
|
using System.Runtime.InteropServices;
|
|
public static class NativeMouse {
|
|
[DllImport("user32.dll", SetLastError=true)]
|
|
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, nuint dwExtraInfo);
|
|
|
|
[DllImport("user32.dll", SetLastError=true)]
|
|
public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, nuint dwExtraInfo);
|
|
}
|
|
"@
|
|
|
|
$root = (Resolve-Path ".").Path
|
|
$clientExe = (Resolve-Path $ClientExePath).Path
|
|
$clientOutputDir = Split-Path -Parent $clientExe
|
|
$appSettingsPath = Join-Path $clientOutputDir "appsettings.json"
|
|
$localStateRoot = Join-Path $env:LOCALAPPDATA "RemoteAccessPlatform\WindowsClient"
|
|
$tokenFile = Join-Path $localStateRoot "auth-state.dat"
|
|
$settingsFile = Join-Path $localStateRoot "client-settings.json"
|
|
$clientLogFile = Join-Path $localStateRoot "logs\desktop-client.log"
|
|
|
|
function Write-Log {
|
|
param([string]$Message)
|
|
Write-Host "[$(Get-Date -Format HH:mm:ss)] $Message"
|
|
}
|
|
|
|
function Assert-NoRawMessageLeak {
|
|
param(
|
|
[string]$Text,
|
|
[string]$Context
|
|
)
|
|
|
|
$forbiddenFragments = @(
|
|
"message_key",
|
|
"fallback_message",
|
|
"trace_id",
|
|
"auth.invalid_credentials",
|
|
"errors.",
|
|
"events.",
|
|
'{"error"',
|
|
'"error":'
|
|
)
|
|
|
|
foreach ($fragment in $forbiddenFragments) {
|
|
if ($Text -like "*$fragment*") {
|
|
throw "$Context surfaced a raw backend/internal message fragment: $fragment`nText: $Text"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-ClientStatusText {
|
|
param([hashtable]$Instance)
|
|
|
|
$statusTexts = @()
|
|
foreach ($automationId in @("FooterStatusText", "MainStatusText")) {
|
|
try {
|
|
$element = Find-ByAutomationId -RootElement $Instance.Window -AutomationId $automationId -TimeoutSeconds 1
|
|
if ($element -and -not [string]::IsNullOrWhiteSpace($element.Current.Name)) {
|
|
$statusTexts += [string]$element.Current.Name
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
return ($statusTexts | Select-Object -Unique)
|
|
}
|
|
|
|
function Test-ClientMessageResolution {
|
|
$applicationAssemblyPath = (Get-Item "clients/windows/src/RemoteAccessPlatform.Windows.Application/bin/Debug/net10.0/RemoteAccessPlatform.Windows.Application.dll").FullName
|
|
$assembly = [System.Reflection.Assembly]::LoadFrom($applicationAssemblyPath)
|
|
$stringsType = $assembly.GetType("RemoteAccessPlatform.Windows.Application.Localization.Strings", $true)
|
|
|
|
$resolvedKnown = $stringsType.InvokeMember("ResolveBackendMessage", [System.Reflection.BindingFlags]::InvokeMethod -bor [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::Static, $null, $null, @("errors.auth.invalid_credentials", "Fallback should not win", "auth.invalid_credentials"))
|
|
if ($resolvedKnown -ne "Invalid credentials.") {
|
|
throw "Known message_key did not resolve to localized text. Actual: $resolvedKnown"
|
|
}
|
|
|
|
$resolvedFallback = $stringsType.InvokeMember("ResolveBackendMessage", [System.Reflection.BindingFlags]::InvokeMethod -bor [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::Static, $null, $null, @("errors.smoke.missing_key", "Fallback smoke message.", "smoke.missing_key"))
|
|
if ($resolvedFallback -ne "Fallback smoke message.") {
|
|
throw "Fallback message was not used when localization key was missing. Actual: $resolvedFallback"
|
|
}
|
|
}
|
|
|
|
function Invoke-RemoteDockerCommand {
|
|
param([string]$Command)
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($DockerSshAlias)) {
|
|
$output = & ssh $DockerSshAlias $Command 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Remote command failed through SSH alias '$DockerSshAlias': $Command`n$output"
|
|
}
|
|
|
|
return $output
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($RemoteDockerHost) -or
|
|
[string]::IsNullOrWhiteSpace($RemoteDockerUser) -or
|
|
[string]::IsNullOrWhiteSpace($RemoteDockerPassword)) {
|
|
throw "Remote docker host credentials or DockerSshAlias are required for remote Docker verification."
|
|
}
|
|
|
|
$plink = (Get-Command plink.exe -ErrorAction Stop).Source
|
|
$hostKey = "ssh-ed25519 255 SHA256:QZ3H40VKtnI1BXDJ/cKYaHgN/oAipzlptkW9v/BwOYg"
|
|
$output = & $plink -batch -hostkey $hostKey -ssh -l $RemoteDockerUser -pw $RemoteDockerPassword $RemoteDockerHost $Command 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Remote command failed: $Command`n$output"
|
|
}
|
|
|
|
return $output
|
|
}
|
|
|
|
function Invoke-RemotePostgresScalar {
|
|
param([string]$Sql)
|
|
|
|
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Sql))
|
|
$command = "printf '%s' '$encoded' | base64 -d | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -t -A -v ON_ERROR_STOP=1"
|
|
$output = Invoke-RemoteDockerCommand -Command $command
|
|
return (($output | Select-Object -Last 1) -as [string]).Trim()
|
|
}
|
|
|
|
function Set-SmokeResourceClipboardMode {
|
|
param([ValidateSet("disabled", "client_to_server", "server_to_client", "bidirectional")][string]$Mode)
|
|
|
|
$escapedName = $DefaultResourceName.Replace("'", "''")
|
|
$sql = @"
|
|
UPDATE resource_policies rp
|
|
SET clipboard_mode = '$Mode',
|
|
clipboard_enabled = CASE WHEN '$Mode' = 'disabled' THEN FALSE ELSE TRUE END,
|
|
updated_at = NOW()
|
|
FROM resources r
|
|
WHERE rp.resource_id = r.id
|
|
AND r.name = '$escapedName';
|
|
SELECT COALESCE(MAX(rp.clipboard_mode), '<missing>')
|
|
FROM resource_policies rp
|
|
JOIN resources r ON r.id = rp.resource_id
|
|
WHERE r.name = '$escapedName';
|
|
"@
|
|
$actual = Invoke-RemotePostgresScalar -Sql $sql
|
|
if ($actual -ne $Mode) {
|
|
throw "Could not set clipboard_mode for '$DefaultResourceName'. Expected '$Mode', actual '$actual'."
|
|
}
|
|
Write-Log "Set resource clipboard_mode=$Mode"
|
|
}
|
|
|
|
function Set-BackendAppSettings {
|
|
$json = @{
|
|
backend = @{
|
|
api_base_url = $BackendApiBase
|
|
gateway_websocket_url = $BackendWsBase
|
|
prefer_direct_data_plane = $PreferDirectDataPlane
|
|
direct_data_plane_connect_timeout_ms = $DirectDataPlaneConnectTimeoutMs
|
|
allow_insecure_direct_data_plane_tls_for_smoke = $AllowInsecureDirectDataPlaneTlsForSmoke
|
|
direct_data_plane_color_mode = $DirectDataPlaneColorMode
|
|
direct_data_plane_platform_ca_bundle = $DirectDataPlanePlatformCaBundle
|
|
environment = $BackendEnvironment
|
|
}
|
|
} | ConvertTo-Json -Depth 4
|
|
Set-Content -LiteralPath $appSettingsPath -Value $json -Encoding UTF8
|
|
}
|
|
|
|
function Reset-ClientState {
|
|
param([string]$Fingerprint)
|
|
|
|
New-Item -ItemType Directory -Force -Path $localStateRoot | Out-Null
|
|
if (Test-Path $tokenFile) {
|
|
Remove-Item -LiteralPath $tokenFile -Force
|
|
}
|
|
|
|
$settings = @{
|
|
deviceFingerprint = $Fingerprint
|
|
lastOrganizationByUserId = @{}
|
|
} | ConvertTo-Json -Depth 4
|
|
Set-Content -LiteralPath $settingsFile -Value $settings -Encoding UTF8
|
|
}
|
|
|
|
function Write-AuthState {
|
|
param(
|
|
[string]$Fingerprint,
|
|
[string]$DeviceLabel
|
|
)
|
|
|
|
$loginBody = @{
|
|
email = $Email
|
|
password = $Password
|
|
trust_device = $true
|
|
device_fingerprint = $Fingerprint
|
|
device_label = $DeviceLabel
|
|
} | ConvertTo-Json
|
|
|
|
$login = Invoke-RestMethod -Method Post -Uri "$BackendApiBase/auth/login" -ContentType "application/json" -Body $loginBody
|
|
|
|
$state = @{
|
|
user = $login.user
|
|
device = $login.device
|
|
authSession = $login.auth_session
|
|
tokens = $login.tokens
|
|
} | ConvertTo-Json -Depth 12
|
|
|
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($state)
|
|
$protected = [System.Security.Cryptography.ProtectedData]::Protect(
|
|
$bytes,
|
|
$null,
|
|
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
)
|
|
|
|
[System.IO.File]::WriteAllBytes($tokenFile, $protected)
|
|
|
|
return $login
|
|
}
|
|
|
|
function Get-WindowByProcess {
|
|
param(
|
|
[int]$ProcessId,
|
|
[int]$TimeoutSeconds = 30
|
|
)
|
|
|
|
$condition = New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::ProcessIdProperty,
|
|
$ProcessId
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$window = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst(
|
|
[System.Windows.Automation.TreeScope]::Children,
|
|
$condition
|
|
)
|
|
if ($window) {
|
|
return $window
|
|
}
|
|
Start-Sleep -Milliseconds 500
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw "Timed out waiting for window for process $ProcessId."
|
|
}
|
|
|
|
function Wait-Window {
|
|
param(
|
|
[string]$TitleLike,
|
|
[int]$TimeoutSeconds = 30
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$windows = [System.Windows.Automation.AutomationElement]::RootElement.FindAll(
|
|
[System.Windows.Automation.TreeScope]::Children,
|
|
[System.Windows.Automation.Condition]::TrueCondition
|
|
)
|
|
|
|
foreach ($window in $windows) {
|
|
if ($window.Current.ControlType.ProgrammaticName -eq "ControlType.Window" -and $window.Current.Name -like $TitleLike) {
|
|
return $window
|
|
}
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 400
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw "Timed out waiting for window title like '$TitleLike'."
|
|
}
|
|
|
|
function Get-WindowsByProcess {
|
|
param([int]$ProcessId)
|
|
|
|
$condition = New-Object System.Windows.Automation.AndCondition(
|
|
(New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::ProcessIdProperty,
|
|
$ProcessId
|
|
)),
|
|
(New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
|
[System.Windows.Automation.ControlType]::Window
|
|
))
|
|
)
|
|
|
|
$collection = [System.Windows.Automation.AutomationElement]::RootElement.FindAll(
|
|
[System.Windows.Automation.TreeScope]::Descendants,
|
|
$condition
|
|
)
|
|
|
|
return @($collection)
|
|
}
|
|
|
|
function Wait-ForAdditionalWindow {
|
|
param(
|
|
[int]$ProcessId,
|
|
[int]$ExistingCount,
|
|
[int]$TimeoutSeconds = 30
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$windows = @(Get-WindowsByProcess -ProcessId $ProcessId)
|
|
if ($windows.Count -gt $ExistingCount) {
|
|
foreach ($window in $windows) {
|
|
if ($window.Current.ControlType.ProgrammaticName -eq "ControlType.Window" -and $window.Current.Name -ne "Remote Access Platform") {
|
|
return $window
|
|
}
|
|
}
|
|
|
|
foreach ($window in $windows) {
|
|
if ($window.Current.ControlType.ProgrammaticName -ne "ControlType.Window") {
|
|
continue
|
|
}
|
|
|
|
$condition = New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::AutomationIdProperty,
|
|
"SessionWindowSessionIdText"
|
|
)
|
|
$sessionMarker = $window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
if ($sessionMarker) {
|
|
return $window
|
|
}
|
|
}
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 400
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw "Timed out waiting for an additional window for process $ProcessId."
|
|
}
|
|
|
|
function Wait-ForWindowWithElement {
|
|
param(
|
|
[int]$ProcessId,
|
|
[string]$AutomationId,
|
|
[int]$TimeoutSeconds = 30
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$windows = @(Get-WindowsByProcess -ProcessId $ProcessId)
|
|
$matches = @()
|
|
foreach ($window in $windows) {
|
|
$condition = New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::AutomationIdProperty,
|
|
$AutomationId
|
|
)
|
|
$item = $window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
if ($item) {
|
|
$matches += $window
|
|
}
|
|
}
|
|
if ($matches.Count -gt 0) {
|
|
$best = $matches | Sort-Object {
|
|
$score = 0
|
|
if (-not $_.Current.IsOffscreen) {
|
|
$score += 1000000
|
|
}
|
|
$rect = $_.Current.BoundingRectangle
|
|
if ($rect.Width -gt 1 -and $rect.Height -gt 1) {
|
|
$score += [int]($rect.Width * $rect.Height)
|
|
}
|
|
try {
|
|
$state = Find-ByAutomationId -RootElement $_ -AutomationId "SessionWindowStateText" -TimeoutSeconds 1
|
|
if ($state.Current.Name -like "*active*") {
|
|
$score += 500000
|
|
}
|
|
} catch {
|
|
}
|
|
try {
|
|
$status = Find-ByAutomationId -RootElement $_ -AutomationId "SessionWindowConnectionStatusText" -TimeoutSeconds 1
|
|
if ($status.Current.Name -like "*Connected*") {
|
|
$score += 250000
|
|
}
|
|
} catch {
|
|
}
|
|
$score
|
|
} -Descending | Select-Object -First 1
|
|
return $best
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 400
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw "Timed out waiting for element '$AutomationId' in process $ProcessId."
|
|
}
|
|
|
|
function Find-ByAutomationId {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$RootElement,
|
|
[string]$AutomationId,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$condition = New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::AutomationIdProperty,
|
|
$AutomationId
|
|
)
|
|
$element = $RootElement.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
if ($element) {
|
|
return $element
|
|
}
|
|
if ($AutomationId -eq "SessionWindowSurface") {
|
|
$frameCondition = New-Object System.Windows.Automation.PropertyCondition(
|
|
[System.Windows.Automation.AutomationElement]::AutomationIdProperty,
|
|
"SessionFrameImage"
|
|
)
|
|
$frameElement = $RootElement.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $frameCondition)
|
|
if ($frameElement) {
|
|
return $frameElement
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 300
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
$snapshot = $RootElement.FindAll(
|
|
[System.Windows.Automation.TreeScope]::Descendants,
|
|
[System.Windows.Automation.Condition]::TrueCondition
|
|
)
|
|
$summary = foreach ($item in $snapshot) {
|
|
$id = $item.Current.AutomationId
|
|
$name = $item.Current.Name
|
|
if (-not [string]::IsNullOrWhiteSpace($id) -or -not [string]::IsNullOrWhiteSpace($name)) {
|
|
"{0} :: {1}" -f $id, $name
|
|
}
|
|
}
|
|
|
|
if ($summary) {
|
|
Write-Log ("Available automation elements:`n" + ($summary | Select-Object -First 60 | Out-String))
|
|
}
|
|
|
|
throw "Timed out waiting for AutomationId '$AutomationId'."
|
|
}
|
|
|
|
function Find-DescendantByNameLike {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$RootElement,
|
|
[string]$MatchText,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
$all = $RootElement.FindAll(
|
|
[System.Windows.Automation.TreeScope]::Descendants,
|
|
[System.Windows.Automation.Condition]::TrueCondition
|
|
)
|
|
|
|
foreach ($element in $all) {
|
|
if ($element.Current.Name -like "*$MatchText*") {
|
|
return $element
|
|
}
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 300
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw "Timed out waiting for element name containing '$MatchText'."
|
|
}
|
|
|
|
function Set-TextValue {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Element,
|
|
[string]$Value
|
|
)
|
|
|
|
$pattern = $Element.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
|
|
$pattern.SetValue($Value)
|
|
}
|
|
|
|
function Invoke-AuthorizedApi {
|
|
param(
|
|
[string]$Method,
|
|
[string]$Path,
|
|
[string]$AccessToken,
|
|
[object]$Body = $null
|
|
)
|
|
|
|
$headers = @{
|
|
Authorization = "Bearer $AccessToken"
|
|
}
|
|
|
|
$invokeParams = @{
|
|
Method = $Method
|
|
Uri = "$BackendApiBase/$Path"
|
|
Headers = $headers
|
|
}
|
|
|
|
if ($null -ne $Body) {
|
|
$invokeParams.ContentType = "application/json"
|
|
$invokeParams.Body = ($Body | ConvertTo-Json -Depth 12)
|
|
}
|
|
|
|
return Invoke-RestMethod @invokeParams
|
|
}
|
|
|
|
function Click-Element {
|
|
param([System.Windows.Automation.AutomationElement]$Element)
|
|
|
|
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
|
try {
|
|
$invoke = $Element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
|
|
$invoke.Invoke()
|
|
return
|
|
} catch {
|
|
try {
|
|
$Element.SetFocus()
|
|
} catch {
|
|
}
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
}
|
|
|
|
try {
|
|
Click-ElementPhysically -Element $Element
|
|
return
|
|
} catch {
|
|
}
|
|
|
|
throw "Could not invoke element."
|
|
}
|
|
|
|
function Find-AncestorWithPattern {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Element,
|
|
[System.Windows.Automation.AutomationPattern]$Pattern
|
|
)
|
|
|
|
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
$current = $Element
|
|
while ($current) {
|
|
$patternObject = $null
|
|
if ($current.TryGetCurrentPattern($Pattern, [ref]$patternObject)) {
|
|
return $current
|
|
}
|
|
$current = $walker.GetParent($current)
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Focus-And-SendKeys {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Element,
|
|
[string]$Text
|
|
)
|
|
|
|
$Element.SetFocus()
|
|
Start-Sleep -Milliseconds 250
|
|
[System.Windows.Forms.SendKeys]::SendWait("^{A}")
|
|
Start-Sleep -Milliseconds 150
|
|
[System.Windows.Forms.Clipboard]::SetText($Text)
|
|
[System.Windows.Forms.SendKeys]::SendWait("^v")
|
|
}
|
|
|
|
function Select-ComboItem {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$ComboBox,
|
|
[string]$ItemText
|
|
)
|
|
|
|
$expand = $ComboBox.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
|
|
$expand.Expand()
|
|
Start-Sleep -Milliseconds 500
|
|
|
|
$item = Find-DescendantByNameLike -RootElement $ComboBox -MatchText $ItemText -TimeoutSeconds 10
|
|
$item = Find-AncestorWithPattern -Element $item -Pattern ([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
if (-not $item) {
|
|
throw "Could not find selectable combo-box item for '$ItemText'."
|
|
}
|
|
$selection = $item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
$selection.Select()
|
|
Start-Sleep -Milliseconds 300
|
|
$expand.Collapse()
|
|
}
|
|
|
|
function Try-Select-ComboItem {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$ComboBox,
|
|
[string]$ItemText
|
|
)
|
|
|
|
try {
|
|
Select-ComboItem -ComboBox $ComboBox -ItemText $ItemText
|
|
return $true
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Select-ListItemByText {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$ListRoot,
|
|
[string]$Text
|
|
)
|
|
|
|
$item = Find-DescendantByNameLike -RootElement $ListRoot -MatchText $Text -TimeoutSeconds 20
|
|
$selectableItem = Find-AncestorWithPattern -Element $item -Pattern ([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
if ($selectableItem) {
|
|
try {
|
|
$selection = $selectableItem.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
$selection.Select()
|
|
} catch {
|
|
try {
|
|
$invokeFallback = Find-AncestorWithPattern -Element $selectableItem -Pattern ([System.Windows.Automation.InvokePattern]::Pattern)
|
|
if ($invokeFallback) {
|
|
$invoke = $invokeFallback.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
|
|
$invoke.Invoke()
|
|
}
|
|
} catch {
|
|
}
|
|
Start-Sleep -Milliseconds 100
|
|
try {
|
|
$selection = $selectableItem.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
$selection.Select()
|
|
} catch {
|
|
try {
|
|
$invokeFallback = Find-AncestorWithPattern -Element $selectableItem -Pattern ([System.Windows.Automation.InvokePattern]::Pattern)
|
|
if ($invokeFallback) {
|
|
$invoke = $invokeFallback.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
|
|
$invoke.Invoke()
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$invokableItem = Find-AncestorWithPattern -Element $item -Pattern ([System.Windows.Automation.InvokePattern]::Pattern)
|
|
if ($invokableItem) {
|
|
$invoke = $invokableItem.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
|
|
$invoke.Invoke()
|
|
} else {
|
|
$item.SetFocus()
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 400
|
|
return $item
|
|
}
|
|
|
|
function Expand-ExpanderByHeaderText {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$RootElement,
|
|
[string]$HeaderText
|
|
)
|
|
|
|
$header = Find-DescendantByNameLike -RootElement $RootElement -MatchText $HeaderText -TimeoutSeconds 10
|
|
$expander = Find-AncestorWithPattern -Element $header -Pattern ([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
|
|
if (-not $expander) {
|
|
throw "Could not find expander for header '$HeaderText'."
|
|
}
|
|
|
|
$pattern = $expander.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
|
|
if ($pattern.Current.ExpandCollapseState -ne [System.Windows.Automation.ExpandCollapseState]::Expanded) {
|
|
$pattern.Expand()
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
|
|
return $expander
|
|
}
|
|
|
|
function Get-ElementText {
|
|
param([System.Windows.Automation.AutomationElement]$Element)
|
|
return $Element.Current.Name
|
|
}
|
|
|
|
function Move-CursorToElement {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Element,
|
|
[double]$RelativeX = 0.5,
|
|
[double]$RelativeY = 0.5
|
|
)
|
|
|
|
$rect = $Element.Current.BoundingRectangle
|
|
if ($rect.Width -le 1 -or $rect.Height -le 1) {
|
|
throw "Automation element has invalid bounds for cursor movement."
|
|
}
|
|
|
|
$x = [int]([Math]::Round($rect.Left + ($rect.Width * $RelativeX)))
|
|
$y = [int]([Math]::Round($rect.Top + ($rect.Height * $RelativeY)))
|
|
[System.Windows.Forms.Cursor]::Position = [System.Drawing.Point]::new($x, $y)
|
|
}
|
|
|
|
function Click-ElementPhysically {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Element,
|
|
[double]$RelativeX = 0.5,
|
|
[double]$RelativeY = 0.5
|
|
)
|
|
|
|
Move-CursorToElement -Element $Element -RelativeX $RelativeX -RelativeY $RelativeY
|
|
Start-Sleep -Milliseconds 120
|
|
[NativeMouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 80
|
|
[NativeMouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
|
}
|
|
|
|
function Send-VirtualKey {
|
|
param([byte]$VirtualKey)
|
|
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 60
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0x0002, [UIntPtr]::Zero)
|
|
}
|
|
|
|
function Send-ControlChordToSessionSurface {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Surface,
|
|
[byte]$VirtualKey,
|
|
[int]$SettleMilliseconds = 700
|
|
)
|
|
|
|
Focus-SessionSurface -Surface $Surface
|
|
[NativeMouse]::keybd_event(0x11, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 60
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 60
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0x0002, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 60
|
|
[NativeMouse]::keybd_event(0x11, 0, 0x0002, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds $SettleMilliseconds
|
|
}
|
|
|
|
function Send-VirtualKeyStroke {
|
|
param(
|
|
[byte]$VirtualKey,
|
|
[switch]$Shift
|
|
)
|
|
|
|
if ($Shift) {
|
|
[NativeMouse]::keybd_event(0x10, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 15
|
|
}
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 25
|
|
[NativeMouse]::keybd_event($VirtualKey, 0, 0x0002, [UIntPtr]::Zero)
|
|
if ($Shift) {
|
|
Start-Sleep -Milliseconds 15
|
|
[NativeMouse]::keybd_event(0x10, 0, 0x0002, [UIntPtr]::Zero)
|
|
}
|
|
Start-Sleep -Milliseconds 15
|
|
}
|
|
|
|
function Send-AsciiTextToSessionSurface {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Surface,
|
|
[string]$Text
|
|
)
|
|
|
|
Focus-SessionSurface -Surface $Surface
|
|
foreach ($ch in $Text.ToCharArray()) {
|
|
$code = [int][char]$ch
|
|
if ($code -ge [int][char]'a' -and $code -le [int][char]'z') {
|
|
Send-VirtualKeyStroke -VirtualKey ([byte](0x41 + ($code - [int][char]'a')))
|
|
} elseif ($code -ge [int][char]'A' -and $code -le [int][char]'Z') {
|
|
Send-VirtualKeyStroke -VirtualKey ([byte](0x41 + ($code - [int][char]'A'))) -Shift
|
|
} elseif ($code -ge [int][char]'0' -and $code -le [int][char]'9') {
|
|
Send-VirtualKeyStroke -VirtualKey ([byte](0x30 + ($code - [int][char]'0')))
|
|
} else {
|
|
switch ($ch) {
|
|
' ' { Send-VirtualKeyStroke -VirtualKey 0x20 }
|
|
'-' { Send-VirtualKeyStroke -VirtualKey 0xBD }
|
|
'/' { Send-VirtualKeyStroke -VirtualKey 0xBF }
|
|
'=' { Send-VirtualKeyStroke -VirtualKey 0xBB }
|
|
'+' { Send-VirtualKeyStroke -VirtualKey 0xBB -Shift }
|
|
default { throw "Unsupported ASCII character for virtual typing: '$ch'" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function New-RemoteSetClipboardCommand {
|
|
param([string]$Text)
|
|
|
|
$encodedTarget = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Text))
|
|
return 'powershell -NoProfile -WindowStyle Hidden -Command "$v=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(''' + $encodedTarget + ''')); Set-Clipboard -Value $v"'
|
|
}
|
|
|
|
function Send-ClientClipboardToRemote {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$SessionSurface,
|
|
[System.Windows.Automation.AutomationElement]$ClipboardSendButton,
|
|
[string]$Text
|
|
)
|
|
|
|
[System.Windows.Forms.Clipboard]::SetText($Text, [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Click-Element -Element $ClipboardSendButton
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
|
|
function Invoke-RemoteClipboardCommandViaClipboard {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$SessionSurface,
|
|
[System.Windows.Automation.AutomationElement]$ClipboardSendButton,
|
|
[string]$RemoteClipboardText
|
|
)
|
|
|
|
$remoteCommand = New-RemoteSetClipboardCommand -Text $RemoteClipboardText
|
|
[System.Windows.Forms.Clipboard]::SetText($remoteCommand, [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Send-KeysToSessionSurface -Surface $SessionSurface -Keys "^{ESC}" -SettleMilliseconds 700
|
|
Click-Element -Element $ClipboardSendButton
|
|
Start-Sleep -Milliseconds 500
|
|
Send-ControlChordToSessionSurface -Surface $SessionSurface -VirtualKey 0x56 -SettleMilliseconds 500
|
|
Focus-SessionSurface -Surface $SessionSurface
|
|
Send-VirtualKey -VirtualKey 0x0D
|
|
Start-Sleep -Milliseconds 2000
|
|
}
|
|
|
|
function Invoke-RemoteClipboardCommandViaKeyboard {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$SessionSurface,
|
|
[string]$RemoteClipboardText
|
|
)
|
|
|
|
$script = '$v=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("' +
|
|
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($RemoteClipboardText)) +
|
|
'"));Set-Clipboard -Value $v'
|
|
$encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($script))
|
|
Send-KeysToSessionSurface -Surface $SessionSurface -Keys "^{ESC}" -SettleMilliseconds 700
|
|
Send-AsciiTextToSessionSurface -Surface $SessionSurface -Text "powershell -enc $encodedCommand"
|
|
Focus-SessionSurface -Surface $SessionSurface
|
|
Send-VirtualKey -VirtualKey 0x0D
|
|
Start-Sleep -Seconds 4
|
|
}
|
|
|
|
function Wait-WorkerLogPattern {
|
|
param(
|
|
[string]$Pattern,
|
|
[datetime]$SinceUtc,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
$since = $SinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Worker log pattern '$Pattern' was not observed." -Condition {
|
|
try {
|
|
$logs = Invoke-RemoteDockerCommand -Command "docker logs --since $since $WorkerContainerName 2>&1"
|
|
return $logs -match [regex]::Escape($Pattern)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Wait-BackendLogPattern {
|
|
param(
|
|
[string]$Pattern,
|
|
[datetime]$SinceUtc,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
$since = $SinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Backend log pattern '$Pattern' was not observed." -Condition {
|
|
try {
|
|
$logs = Invoke-RemoteDockerCommand -Command "docker logs --since $since rap_backend_smoke 2>&1"
|
|
return $logs -match [regex]::Escape($Pattern)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Wait-ClientLogPattern {
|
|
param(
|
|
[string]$Pattern,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Client log pattern '$Pattern' was not observed." -Condition {
|
|
try {
|
|
if (-not (Test-Path $clientLogFile)) {
|
|
return $false
|
|
}
|
|
|
|
$logs = Get-Content $clientLogFile -Raw
|
|
return $logs -match [regex]::Escape($Pattern)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Wait-ClientLogPatternSince {
|
|
param(
|
|
[string]$Pattern,
|
|
[datetime]$SinceLocal,
|
|
[int]$TimeoutSeconds = 20
|
|
)
|
|
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Client log pattern '$Pattern' was not observed after $SinceLocal." -Condition {
|
|
try {
|
|
if (-not (Test-Path $clientLogFile)) {
|
|
return $false
|
|
}
|
|
|
|
foreach ($line in Get-Content $clientLogFile) {
|
|
if ($line -notmatch '^\[(?<ts>[^\]]+)\]') {
|
|
continue
|
|
}
|
|
$timestamp = [datetimeoffset]::Parse($Matches.ts).LocalDateTime
|
|
if ($timestamp -ge $SinceLocal -and $line -match [regex]::Escape($Pattern)) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Focus-SessionSurface {
|
|
param([System.Windows.Automation.AutomationElement]$Surface)
|
|
|
|
Click-ElementPhysically -Element $Surface
|
|
Start-Sleep -Milliseconds 250
|
|
try {
|
|
$Surface.SetFocus()
|
|
} catch {
|
|
}
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
|
|
function Send-KeysToSessionSurface {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Surface,
|
|
[string]$Keys,
|
|
[int]$SettleMilliseconds = 700
|
|
)
|
|
|
|
Focus-SessionSurface -Surface $Surface
|
|
[System.Windows.Forms.SendKeys]::SendWait($Keys)
|
|
Start-Sleep -Milliseconds $SettleMilliseconds
|
|
}
|
|
|
|
function Get-LocalClipboardText {
|
|
if ([System.Windows.Forms.Clipboard]::ContainsText([System.Windows.Forms.TextDataFormat]::UnicodeText)) {
|
|
return [System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function Wait-LocalClipboardEquals {
|
|
param(
|
|
[string]$Expected,
|
|
[int]$TimeoutSeconds = 15
|
|
)
|
|
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Local clipboard never matched expected text." -Condition {
|
|
try {
|
|
return (Get-LocalClipboardText) -eq $Expected
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Wait-SessionEventText {
|
|
param(
|
|
[System.Windows.Automation.AutomationElement]$Window,
|
|
[string]$Text,
|
|
[int]$TimeoutSeconds = 10
|
|
)
|
|
|
|
Wait-Until -TimeoutSeconds $TimeoutSeconds -FailureMessage "Session event text '$Text' was not observed." -Condition {
|
|
try {
|
|
$item = Find-DescendantByNameLike -RootElement $Window -MatchText $Text -TimeoutSeconds 1
|
|
return $item -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-RemoteRedisQueueLength {
|
|
param([string]$SessionId)
|
|
|
|
$output = Invoke-RemoteDockerCommand -Command "docker exec rap_redis redis-cli LLEN worker:queue:$SessionId"
|
|
$value = (($output | Select-Object -Last 1) -as [string]).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($value)) {
|
|
return 0
|
|
}
|
|
return [int]$value
|
|
}
|
|
|
|
function Wait-Until {
|
|
param(
|
|
[scriptblock]$Condition,
|
|
[string]$FailureMessage,
|
|
[int]$TimeoutSeconds = 30
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
do {
|
|
if (& $Condition) {
|
|
return
|
|
}
|
|
Start-Sleep -Milliseconds 500
|
|
} while ((Get-Date) -lt $deadline)
|
|
|
|
throw $FailureMessage
|
|
}
|
|
|
|
function Get-DescendantNames {
|
|
param([System.Windows.Automation.AutomationElement]$RootElement)
|
|
|
|
$all = $RootElement.FindAll(
|
|
[System.Windows.Automation.TreeScope]::Descendants,
|
|
[System.Windows.Automation.Condition]::TrueCondition
|
|
)
|
|
|
|
$names = foreach ($item in $all) {
|
|
if (-not [string]::IsNullOrWhiteSpace($item.Current.Name)) {
|
|
$item.Current.Name
|
|
}
|
|
}
|
|
|
|
return $names | Select-Object -Unique
|
|
}
|
|
|
|
function Start-ClientInstance {
|
|
param(
|
|
[string]$Fingerprint,
|
|
[bool]$Bootstrapped = $UseApiAuthBootstrap
|
|
)
|
|
|
|
Reset-ClientState -Fingerprint $Fingerprint
|
|
$auth = $null
|
|
if ($Bootstrapped) {
|
|
Write-Log "Bootstrapping auth state for $Fingerprint via API"
|
|
$auth = Write-AuthState -Fingerprint $Fingerprint -DeviceLabel $Fingerprint
|
|
}
|
|
$process = Start-Process -FilePath $clientExe -PassThru
|
|
$window = Get-WindowByProcess -ProcessId $process.Id
|
|
return @{
|
|
Process = $process
|
|
Window = $window
|
|
Auth = $auth
|
|
}
|
|
}
|
|
|
|
function Stop-StaleClientProcesses {
|
|
Get-Process | Where-Object { $_.ProcessName -like "RemoteAccessPlatform*" } | ForEach-Object {
|
|
try {
|
|
Stop-Process -Id $_.Id -Force -ErrorAction Stop
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
|
|
function Cleanup-SmokeSessions {
|
|
param([hashtable]$Instance)
|
|
|
|
if (-not $UseApiAuthBootstrap -or -not $Instance.Auth) {
|
|
return
|
|
}
|
|
|
|
$accessToken = [string]$Instance.Auth.tokens.access_token
|
|
$userId = [string]$Instance.Auth.user.id
|
|
$response = Invoke-AuthorizedApi -Method Get -Path ("sessions?user_id={0}" -f [Uri]::EscapeDataString($userId)) -AccessToken $accessToken
|
|
$sessions = @($response.sessions)
|
|
foreach ($session in $sessions) {
|
|
if ($session.state -in @("starting", "active", "detached")) {
|
|
try {
|
|
Invoke-AuthorizedApi -Method Post -Path ("sessions/{0}/terminate" -f $session.id) -AccessToken $accessToken -Body @{
|
|
user_id = $userId
|
|
reason = "windows_smoke_pre_cleanup"
|
|
} | Out-Null
|
|
} catch {
|
|
Write-Log "Pre-cleanup terminate skipped for session $($session.id): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Login-Client {
|
|
param(
|
|
[hashtable]$Instance,
|
|
[string]$LoginEmail = $Email,
|
|
[string]$LoginPassword = $Password,
|
|
[switch]$ExpectFailure
|
|
)
|
|
|
|
$emailBox = Find-ByAutomationId -RootElement $Instance.Window -AutomationId "LoginEmailTextBox"
|
|
$passwordBox = Find-ByAutomationId -RootElement $Instance.Window -AutomationId "LoginPasswordBox"
|
|
$signInButton = Find-ByAutomationId -RootElement $Instance.Window -AutomationId "SignInButton"
|
|
|
|
Set-TextValue -Element $emailBox -Value $LoginEmail
|
|
Focus-And-SendKeys -Element $passwordBox -Text $LoginPassword
|
|
Click-Element -Element $signInButton
|
|
|
|
if ($ExpectFailure) {
|
|
$script:__invalidLoginStatusText = $null
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Invalid login did not surface an error message." -Condition {
|
|
try {
|
|
$script:__invalidLoginStatusText = @(Get-ClientStatusText -Instance $Instance | Where-Object { $_ -like "*Invalid credentials.*" }) | Select-Object -First 1
|
|
return -not [string]::IsNullOrWhiteSpace($script:__invalidLoginStatusText)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
return $script:__invalidLoginStatusText
|
|
}
|
|
|
|
try {
|
|
Wait-Until -TimeoutSeconds 30 -FailureMessage "Login did not complete." -Condition {
|
|
try {
|
|
$currentUser = Find-ByAutomationId -RootElement $Instance.Window -AutomationId "CurrentUserText" -TimeoutSeconds 2
|
|
return $currentUser.Current.Name -like "*$LoginEmail*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
} catch {
|
|
try {
|
|
$status = Find-ByAutomationId -RootElement $Instance.Window -AutomationId "FooterStatusText" -TimeoutSeconds 2
|
|
throw "Login did not complete. Footer status: $($status.Current.Name)"
|
|
} catch {
|
|
throw
|
|
}
|
|
}
|
|
|
|
$Instance.Window = Get-WindowByProcess -ProcessId $Instance.Process.Id -TimeoutSeconds 10
|
|
}
|
|
|
|
function Stop-ClientInstance {
|
|
param([hashtable]$Instance)
|
|
if (-not $Instance.Process.HasExited) {
|
|
$Instance.Process.CloseMainWindow() | Out-Null
|
|
Start-Sleep -Seconds 2
|
|
if (-not $Instance.Process.HasExited) {
|
|
Stop-Process -Id $Instance.Process.Id -Force
|
|
}
|
|
}
|
|
}
|
|
|
|
Set-BackendAppSettings
|
|
Write-Log "Configured appsettings.json for $BackendApiBase"
|
|
Stop-StaleClientProcesses
|
|
|
|
$result = [ordered]@{
|
|
login_error = $false
|
|
fallback_message = $false
|
|
login = $false
|
|
organization_switch = $false
|
|
resource_list = $false
|
|
token_refresh = $false
|
|
start = $false
|
|
focus_input = $false
|
|
keyboard_input = $false
|
|
mouse_input = $false
|
|
clipboard_server_to_client_english = $false
|
|
clipboard_server_to_client_russian = $false
|
|
clipboard_server_to_client_special = $false
|
|
clipboard_disabled_client_to_server_blocked = $false
|
|
clipboard_disabled_server_to_client_blocked = $false
|
|
clipboard_client_to_server_allowed = $false
|
|
clipboard_client_to_server_server_to_client_blocked = $false
|
|
clipboard_server_to_client_allowed = $false
|
|
clipboard_server_to_client_client_to_server_blocked = $false
|
|
clipboard_bidirectional_client_to_server_allowed = $false
|
|
clipboard_bidirectional_server_to_client_allowed = $false
|
|
clipboard_size_limit_blocked = $false
|
|
clipboard_detach_blocked = $false
|
|
clipboard_takeover_old_client_blocked = $false
|
|
clipboard_worker_failure_blocked = $false
|
|
rendering = $false
|
|
detach = $false
|
|
attach = $false
|
|
takeover = $false
|
|
takeover_event = $false
|
|
worker_death = $false
|
|
failed_input_blocked = $false
|
|
failed_queue_stable = $false
|
|
logout = $false
|
|
}
|
|
|
|
$instanceA = $null
|
|
$instanceB = $null
|
|
$workerStoppedForVerification = $false
|
|
|
|
try {
|
|
$narrowVerification = $VerifyWorkerDeath -or $VerifyClipboardServerToClient -or $VerifyClipboardMatrix
|
|
|
|
if (-not $narrowVerification) {
|
|
$invalidLoginInstance = Start-ClientInstance -Fingerprint "windows-desktop-invalid-login" -Bootstrapped:$false
|
|
try {
|
|
$invalidLoginStatus = Login-Client -Instance $invalidLoginInstance -LoginPassword "SmokePass!123-invalid" -ExpectFailure
|
|
if ($invalidLoginStatus -notlike "*Invalid credentials.*") {
|
|
throw "Invalid login surfaced unexpected footer status: $invalidLoginStatus"
|
|
}
|
|
Assert-NoRawMessageLeak -Text $invalidLoginStatus -Context "Invalid login footer"
|
|
$result.login_error = $true
|
|
Write-Log "Invalid login error rendering verified"
|
|
} finally {
|
|
if ($invalidLoginInstance) {
|
|
Stop-ClientInstance -Instance $invalidLoginInstance
|
|
}
|
|
}
|
|
} else {
|
|
Write-Log "Skipping invalid-login rendering check for the narrow verification pass"
|
|
}
|
|
|
|
$instanceA = Start-ClientInstance -Fingerprint "windows-desktop-smoke-a"
|
|
if ($UseApiAuthBootstrap) {
|
|
Wait-Until -TimeoutSeconds 30 -FailureMessage "Bootstrapped client A did not reach authenticated shell." -Condition {
|
|
try {
|
|
$currentUser = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "CurrentUserText" -TimeoutSeconds 2
|
|
return $currentUser.Current.Name -like "*$Email*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
} else {
|
|
Login-Client -Instance $instanceA
|
|
}
|
|
$result.login = $true
|
|
Write-Log "Client A logged in"
|
|
Cleanup-SmokeSessions -Instance $instanceA
|
|
|
|
$resourceList = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "ResourceListView"
|
|
if ($VerifyClipboardServerToClient -or $VerifyClipboardMatrix -or $SkipOrgSwitchAndTokenRefresh) {
|
|
Write-Log "Skipping org-switch and token-refresh checks for narrow verification"
|
|
$orgCombo = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "OrganizationComboBox"
|
|
$setOrgButton = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "SetActiveOrganizationButton"
|
|
$selectedDefaultOrg = Try-Select-ComboItem -ComboBox $orgCombo -ItemText "Default Organization"
|
|
if ($selectedDefaultOrg) {
|
|
Click-Element -Element $setOrgButton
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Default resource list did not appear." -Condition {
|
|
try {
|
|
$item = Find-DescendantByNameLike -RootElement $resourceList -MatchText $DefaultResourceName -TimeoutSeconds 2
|
|
return $item -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$result.resource_list = $true
|
|
} else {
|
|
$orgCombo = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "OrganizationComboBox"
|
|
$setOrgButton = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "SetActiveOrganizationButton"
|
|
Write-Log "Selecting alt organization"
|
|
Select-ComboItem -ComboBox $orgCombo -ItemText $AltOrganizationName
|
|
Click-Element -Element $setOrgButton
|
|
|
|
try {
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Alt org resource list did not appear." -Condition {
|
|
try {
|
|
$item = Find-DescendantByNameLike -RootElement $resourceList -MatchText $AltResourceName -TimeoutSeconds 2
|
|
return $item -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
} catch {
|
|
$status = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "FooterStatusText" -TimeoutSeconds 2
|
|
$availableNames = Get-DescendantNames -RootElement $resourceList
|
|
Write-Log ("Resource list names after org switch:`n" + ($availableNames | Select-Object -First 40 | Out-String))
|
|
throw "Alt org resource list did not appear. Footer status: $($status.Current.Name)"
|
|
}
|
|
$result.organization_switch = $true
|
|
$result.resource_list = $true
|
|
Write-Log "Organization switch to alt org succeeded"
|
|
|
|
Write-Log "Switching back to default organization"
|
|
Select-ComboItem -ComboBox $orgCombo -ItemText "Default Organization"
|
|
Click-Element -Element $setOrgButton
|
|
try {
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Default org resource list did not appear." -Condition {
|
|
try {
|
|
$item = Find-DescendantByNameLike -RootElement $resourceList -MatchText $DefaultResourceName -TimeoutSeconds 2
|
|
return $item -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
} catch {
|
|
$status = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "FooterStatusText" -TimeoutSeconds 2
|
|
$availableNames = Get-DescendantNames -RootElement $resourceList
|
|
Write-Log ("Resource list names after switching back:`n" + ($availableNames | Select-Object -First 40 | Out-String))
|
|
throw "Default org resource list did not appear. Footer status: $($status.Current.Name)"
|
|
}
|
|
Write-Log "Switched back to default org"
|
|
|
|
Write-Log "Waiting for access token expiry to verify refresh"
|
|
Start-Sleep -Seconds 25
|
|
$refreshResourcesButton = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "RefreshResourcesButton"
|
|
Click-Element -Element $refreshResourcesButton
|
|
Wait-Until -TimeoutSeconds 15 -FailureMessage "Refresh after token expiry did not complete." -Condition {
|
|
try {
|
|
$status = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "FooterStatusText" -TimeoutSeconds 2
|
|
return $status.Current.Name -like "*Loaded*resources*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$result.token_refresh = $true
|
|
Write-Log "Token refresh path survived expired access token"
|
|
}
|
|
|
|
if ($VerifyClipboardMatrix) {
|
|
if ([string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
throw "Remote docker host is required for clipboard matrix verification."
|
|
}
|
|
|
|
$accessToken = [string]$instanceA.Auth.tokens.access_token
|
|
$userId = [string]$instanceA.Auth.user.id
|
|
|
|
function Start-ClipboardMatrixSession {
|
|
param([string]$Mode)
|
|
|
|
Cleanup-SmokeSessions -Instance $instanceA
|
|
Set-SmokeResourceClipboardMode -Mode $Mode
|
|
Click-Element -Element (Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "RefreshResourcesButton")
|
|
Start-Sleep -Milliseconds 500
|
|
$null = Select-ListItemByText -ListRoot $resourceList -Text $DefaultResourceName
|
|
$startButton = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "StartSessionButton"
|
|
Click-Element -Element $startButton
|
|
$window = Wait-ForWindowWithElement -ProcessId $instanceA.Process.Id -AutomationId "SessionWindowSessionIdText" -TimeoutSeconds 25
|
|
$sessionIdText = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowSessionIdText"
|
|
$stateText = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowStateText"
|
|
$connectionStatus = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowConnectionStatusText"
|
|
Wait-Until -TimeoutSeconds 25 -FailureMessage "Clipboard matrix session did not connect for mode $Mode." -Condition { $connectionStatus.Current.Name -like "*Connected*" }
|
|
Wait-Until -TimeoutSeconds 25 -FailureMessage "Clipboard matrix session did not become active for mode $Mode." -Condition { $stateText.Current.Name -like "*active*" }
|
|
$surface = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowSurface"
|
|
$null = Expand-ExpanderByHeaderText -RootElement $window -HeaderText "Rendering"
|
|
$sendButton = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowClipboardSendButton"
|
|
$terminateButton = Find-ByAutomationId -RootElement $window -AutomationId "SessionWindowTerminateButton"
|
|
return @{
|
|
Window = $window
|
|
SessionId = $sessionIdText.Current.Name
|
|
StateText = $stateText
|
|
ConnectionStatus = $connectionStatus
|
|
Surface = $surface
|
|
SendButton = $sendButton
|
|
TerminateButton = $terminateButton
|
|
}
|
|
}
|
|
|
|
function Stop-ClipboardMatrixSession {
|
|
param([hashtable]$Session)
|
|
|
|
if (-not $Session -or [string]::IsNullOrWhiteSpace($Session.SessionId)) {
|
|
return
|
|
}
|
|
try {
|
|
Invoke-AuthorizedApi -Method Post -Path ("sessions/{0}/terminate" -f $Session.SessionId) -AccessToken $accessToken -Body @{
|
|
user_id = $userId
|
|
reason = "windows_clipboard_matrix_cleanup"
|
|
} | Out-Null
|
|
Start-Sleep -Seconds 1
|
|
} catch {
|
|
Write-Log "Clipboard matrix terminate skipped for session $($Session.SessionId): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Test-ClientToServerAllowed {
|
|
param([hashtable]$Session, [string]$Text)
|
|
|
|
$caseStart = (Get-Date).ToUniversalTime()
|
|
Send-ClientClipboardToRemote -SessionSurface $Session.Surface -ClipboardSendButton $Session.SendButton -Text $Text
|
|
Wait-WorkerLogPattern -Pattern "forwarded text clipboard to FreeRDP cliprdr boundary for session $($Session.SessionId)" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
}
|
|
|
|
function Test-ClientToServerBlocked {
|
|
param([hashtable]$Session, [string]$Text)
|
|
|
|
$caseStartLocal = Get-Date
|
|
Send-ClientClipboardToRemote -SessionSurface $Session.Surface -ClipboardSendButton $Session.SendButton -Text $Text
|
|
Wait-ClientLogPatternSince -Pattern "SessionWindowViewModel.HandleEnvelopeAsync: $($Session.SessionId) clipboard.blocked" -SinceLocal $caseStartLocal -TimeoutSeconds 20
|
|
}
|
|
|
|
function Test-ServerToClientAllowed {
|
|
param([hashtable]$Session, [string]$Expected)
|
|
|
|
$caseStart = (Get-Date).ToUniversalTime()
|
|
[System.Windows.Forms.Clipboard]::SetText("local-sentinel-$Expected", [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Invoke-RemoteClipboardCommandViaClipboard -SessionSurface $Session.Surface -ClipboardSendButton $Session.SendButton -RemoteClipboardText $Expected
|
|
Wait-WorkerLogPattern -Pattern "publishing clipboard notification for session $($Session.SessionId)" -SinceUtc $caseStart -TimeoutSeconds 25
|
|
Wait-BackendLogPattern -Pattern "session gateway writing clipboard envelope" -SinceUtc $caseStart -TimeoutSeconds 25
|
|
Wait-ClientLogPatternSince -Pattern "SessionWindow applied server clipboard text length=" -SinceLocal (Get-Date).AddSeconds(-30) -TimeoutSeconds 25
|
|
Wait-LocalClipboardEquals -Expected $Expected -TimeoutSeconds 20
|
|
}
|
|
|
|
function Test-ServerToClientBlockedAfterRemoteChange {
|
|
param([hashtable]$Session, [string]$RemoteText)
|
|
|
|
$caseStart = (Get-Date).ToUniversalTime()
|
|
[System.Windows.Forms.Clipboard]::SetText("local-sentinel-blocked-$RemoteText", [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Invoke-RemoteClipboardCommandViaClipboard -SessionSurface $Session.Surface -ClipboardSendButton $Session.SendButton -RemoteClipboardText $RemoteText
|
|
Wait-WorkerLogPattern -Pattern "publishing clipboard notification for session $($Session.SessionId)" -SinceUtc $caseStart -TimeoutSeconds 25
|
|
Wait-BackendLogPattern -Pattern "worker clipboard text ignored by policy or state" -SinceUtc $caseStart -TimeoutSeconds 25
|
|
Start-Sleep -Seconds 3
|
|
if ((Get-LocalClipboardText) -eq $RemoteText) {
|
|
throw "Server-to-client clipboard was not blocked for session $($Session.SessionId)."
|
|
}
|
|
}
|
|
|
|
Write-Log "Running final Stage 4 clipboard policy matrix"
|
|
|
|
$disabled = Start-ClipboardMatrixSession -Mode "disabled"
|
|
Test-ClientToServerBlocked -Session $disabled -Text "matrix-disabled-c2s"
|
|
$result.clipboard_disabled_client_to_server_blocked = $true
|
|
[System.Windows.Forms.Clipboard]::SetText("local-sentinel-disabled-s2c", [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Start-Sleep -Seconds 3
|
|
if ((Get-LocalClipboardText) -ne "local-sentinel-disabled-s2c") {
|
|
throw "Disabled policy allowed server-to-client clipboard."
|
|
}
|
|
$result.clipboard_disabled_server_to_client_blocked = $true
|
|
Stop-ClipboardMatrixSession -Session $disabled
|
|
|
|
$clientToServer = Start-ClipboardMatrixSession -Mode "client_to_server"
|
|
Test-ClientToServerAllowed -Session $clientToServer -Text "matrix-client-to-server"
|
|
$result.clipboard_client_to_server_allowed = $true
|
|
Test-ServerToClientBlockedAfterRemoteChange -Session $clientToServer -RemoteText "matrix-client-to-server-server-blocked"
|
|
$result.clipboard_client_to_server_server_to_client_blocked = $true
|
|
Stop-ClipboardMatrixSession -Session $clientToServer
|
|
|
|
$serverToClientExpected = "matrix server-to-client policy allowed"
|
|
[System.Windows.Forms.Clipboard]::SetText("local-sentinel-server-to-client-policy", [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
$serverToClient = Start-ClipboardMatrixSession -Mode "bidirectional"
|
|
$serverToClientStart = (Get-Date).ToUniversalTime()
|
|
$serverToClientCommand = New-RemoteSetClipboardCommand -Text $serverToClientExpected
|
|
[System.Windows.Forms.Clipboard]::SetText($serverToClientCommand, [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Send-KeysToSessionSurface -Surface $serverToClient.Surface -Keys "^{ESC}" -SettleMilliseconds 700
|
|
Click-Element -Element $serverToClient.SendButton
|
|
Start-Sleep -Milliseconds 500
|
|
Set-SmokeResourceClipboardMode -Mode "server_to_client"
|
|
Send-ControlChordToSessionSurface -Surface $serverToClient.Surface -VirtualKey 0x56 -SettleMilliseconds 500
|
|
Focus-SessionSurface -Surface $serverToClient.Surface
|
|
Send-VirtualKey -VirtualKey 0x0D
|
|
Start-Sleep -Milliseconds 2000
|
|
Wait-WorkerLogPattern -Pattern "publishing clipboard notification for session $($serverToClient.SessionId)" -SinceUtc $serverToClientStart -TimeoutSeconds 25
|
|
Wait-BackendLogPattern -Pattern "session gateway writing clipboard envelope" -SinceUtc $serverToClientStart -TimeoutSeconds 25
|
|
Wait-LocalClipboardEquals -Expected $serverToClientExpected -TimeoutSeconds 20
|
|
$result.clipboard_server_to_client_allowed = $true
|
|
Test-ClientToServerBlocked -Session $serverToClient -Text "matrix-server-to-client-c2s-blocked"
|
|
$result.clipboard_server_to_client_client_to_server_blocked = $true
|
|
Stop-ClipboardMatrixSession -Session $serverToClient
|
|
|
|
$bidirectional = Start-ClipboardMatrixSession -Mode "bidirectional"
|
|
Test-ClientToServerAllowed -Session $bidirectional -Text "matrix-bidirectional-c2s"
|
|
$result.clipboard_bidirectional_client_to_server_allowed = $true
|
|
Test-ServerToClientAllowed -Session $bidirectional -Expected "server-client smoke english1"
|
|
Test-ServerToClientAllowed -Session $bidirectional -Expected "сервер-клиент smoke русский1"
|
|
Test-ServerToClientAllowed -Session $bidirectional -Expected "clipboard special 🙂 §1"
|
|
$result.clipboard_bidirectional_server_to_client_allowed = $true
|
|
|
|
$sizeStart = Get-Date
|
|
Send-ClientClipboardToRemote -SessionSurface $bidirectional.Surface -ClipboardSendButton $bidirectional.SendButton -Text ("x" * (1024 * 1024 + 1))
|
|
Wait-SessionEventText -Window $bidirectional.Window -Text "Clipboard transfer is blocked" -TimeoutSeconds 10
|
|
$result.clipboard_size_limit_blocked = $true
|
|
|
|
Write-Log "Verifying detach clipboard gating"
|
|
$detachButton = Find-ByAutomationId -RootElement $bidirectional.Window -AutomationId "SessionWindowDetachButton"
|
|
Click-Element -Element $detachButton
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Clipboard matrix detach did not surface detached state." -Condition { $bidirectional.StateText.Current.Name -like "*detached*" }
|
|
Send-ClientClipboardToRemote -SessionSurface $bidirectional.Surface -ClipboardSendButton $bidirectional.SendButton -Text "matrix-detached-blocked"
|
|
Wait-SessionEventText -Window $bidirectional.Window -Text "Clipboard transfer is blocked" -TimeoutSeconds 10
|
|
$result.detach = $true
|
|
$result.clipboard_detach_blocked = $true
|
|
|
|
Write-Log "Reattaching for takeover and worker-death clipboard gates"
|
|
$reconnectButton = Find-ByAutomationId -RootElement $bidirectional.Window -AutomationId "SessionWindowReconnectButton"
|
|
Click-Element -Element $reconnectButton
|
|
$sessionWindowA2 = Wait-ForWindowWithElement -ProcessId $instanceA.Process.Id -AutomationId "SessionWindowSessionIdText"
|
|
$sessionIdA2 = (Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowSessionIdText").Current.Name
|
|
$stateTextA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowStateText"
|
|
Wait-Until -TimeoutSeconds 25 -FailureMessage "Clipboard matrix reattach did not become active." -Condition { $stateTextA2.Current.Name -like "*active*" }
|
|
$surfaceA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowSurface"
|
|
$null = Expand-ExpanderByHeaderText -RootElement $sessionWindowA2 -HeaderText "Rendering"
|
|
$sendButtonA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowClipboardSendButton"
|
|
$result.attach = $true
|
|
|
|
Write-Log "Launching client B for takeover clipboard gate"
|
|
$instanceB = Start-ClientInstance -Fingerprint "windows-desktop-smoke-b"
|
|
Wait-Until -TimeoutSeconds 30 -FailureMessage "Bootstrapped client B did not reach authenticated shell." -Condition {
|
|
try {
|
|
$currentUser = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "CurrentUserText" -TimeoutSeconds 2
|
|
return $currentUser.Current.Name -like "*$Email*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$sessionListB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "SessionListView"
|
|
$refreshSessionsButtonB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "RefreshSessionsButton"
|
|
Click-Element -Element $refreshSessionsButtonB
|
|
$takeOverButtonB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "TakeOverSessionButton"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Clipboard matrix takeover button never enabled." -Condition {
|
|
try {
|
|
$null = Select-ListItemByText -ListRoot $sessionListB -Text $sessionIdA2
|
|
Start-Sleep -Milliseconds 200
|
|
return $takeOverButtonB.Current.IsEnabled
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
Click-Element -Element $takeOverButtonB
|
|
$sessionWindowB = Wait-ForWindowWithElement -ProcessId $instanceB.Process.Id -AutomationId "SessionWindowSessionIdText"
|
|
$stateTextB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowStateText"
|
|
Wait-Until -TimeoutSeconds 25 -FailureMessage "Clipboard matrix takeover target did not become active." -Condition { $stateTextB.Current.Name -like "*active*" }
|
|
$result.takeover = $true
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Clipboard matrix old client did not show taken-over status." -Condition {
|
|
try {
|
|
$statusA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowConnectionStatusText" -TimeoutSeconds 2
|
|
return $statusA2.Current.Name -like "*Taken over*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$result.takeover_event = $true
|
|
$takeoverBlockStart = Get-Date
|
|
Send-ClientClipboardToRemote -SessionSurface $surfaceA2 -ClipboardSendButton $sendButtonA2 -Text "matrix-old-client-blocked"
|
|
Wait-SessionEventText -Window $sessionWindowA2 -Text "Clipboard transfer is blocked" -TimeoutSeconds 10
|
|
$result.clipboard_takeover_old_client_blocked = $true
|
|
|
|
$surfaceB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowSurface"
|
|
$null = Expand-ExpanderByHeaderText -RootElement $sessionWindowB -HeaderText "Rendering"
|
|
$sendButtonB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowClipboardSendButton"
|
|
Write-Log "Stopping worker container to verify clipboard gate after worker failure"
|
|
Invoke-RemoteDockerCommand -Command "docker kill $WorkerContainerName" | Out-Null
|
|
$workerStoppedForVerification = $true
|
|
Wait-Until -TimeoutSeconds 140 -FailureMessage "Clipboard matrix client B did not observe failed session state after worker death." -Condition {
|
|
try {
|
|
return $stateTextB.Current.Name -like "*failed*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$workerFailureBlockStart = Get-Date
|
|
Send-ClientClipboardToRemote -SessionSurface $surfaceB -ClipboardSendButton $sendButtonB -Text "matrix-worker-failure-blocked"
|
|
Wait-SessionEventText -Window $sessionWindowB -Text "Clipboard transfer is blocked" -TimeoutSeconds 10
|
|
$result.worker_death = $true
|
|
$result.clipboard_worker_failure_blocked = $true
|
|
|
|
$result.start = $true
|
|
$result.rendering = $true
|
|
$result.focus_input = $true
|
|
$result.keyboard_input = $true
|
|
$result.mouse_input = $true
|
|
$result.resource_list = $true
|
|
|
|
$result | ConvertTo-Json -Depth 4
|
|
return
|
|
}
|
|
|
|
Write-Log "Selecting default resource and starting session"
|
|
$null = Select-ListItemByText -ListRoot $resourceList -Text $DefaultResourceName
|
|
$startButton = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "StartSessionButton"
|
|
$sessionWindowA1 = $null
|
|
for ($attempt = 1; $attempt -le 2 -and -not $sessionWindowA1; $attempt++) {
|
|
Click-Element -Element $startButton
|
|
try {
|
|
$sessionWindowA1 = Wait-ForWindowWithElement -ProcessId $instanceA.Process.Id -AutomationId "SessionWindowSessionIdText" -TimeoutSeconds 15
|
|
} catch {
|
|
if ($attempt -ge 2) {
|
|
$status = Find-ByAutomationId -RootElement $instanceA.Window -AutomationId "FooterStatusText" -TimeoutSeconds 2
|
|
throw "Session window did not open after start. Footer status: $($status.Current.Name)"
|
|
}
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
}
|
|
$sessionIdTextA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowSessionIdText"
|
|
$sessionId = $sessionIdTextA1.Current.Name
|
|
$stateTextA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowStateText"
|
|
$connectionStatusA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowConnectionStatusText"
|
|
$eventLogA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowEventLog"
|
|
$inputStatusA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowInputStatusText"
|
|
$sessionSurfaceA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowSurface"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Connected session status did not appear." -Condition { $connectionStatusA1.Current.Name -like "*Connected*" }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Active session state did not appear before input verification." -Condition { $stateTextA1.Current.Name -like "*active*" }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Session state fallback message or active state did not appear." -Condition {
|
|
try {
|
|
if ($stateTextA1.Current.Name -like "*active*") {
|
|
return $true
|
|
}
|
|
$item = Find-DescendantByNameLike -RootElement $eventLogA1 -MatchText "Session state updated." -TimeoutSeconds 2
|
|
return $item -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
Assert-NoRawMessageLeak -Text $connectionStatusA1.Current.Name -Context "Session connected status"
|
|
try {
|
|
$renderProfileText = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowRenderProfileText" -TimeoutSeconds 3
|
|
$renderStateText = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowRenderStateText" -TimeoutSeconds 3
|
|
$renderSizeText = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowRenderSizeText" -TimeoutSeconds 3
|
|
$cursorStatusText = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowCursorStatusText" -TimeoutSeconds 3
|
|
$renderTelemetryText = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowRenderTelemetryText" -TimeoutSeconds 3
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Render profile text did not appear." -Condition { -not [string]::IsNullOrWhiteSpace($renderProfileText.Current.Name) }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Render state text did not appear." -Condition { -not [string]::IsNullOrWhiteSpace($renderStateText.Current.Name) }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Render size text did not appear." -Condition { $renderSizeText.Current.Name -notlike "*unknown*" }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Cursor status text did not appear." -Condition { -not [string]::IsNullOrWhiteSpace($cursorStatusText.Current.Name) }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Render telemetry text did not appear." -Condition { $renderTelemetryText.Current.Name -notlike "*unknown*" }
|
|
Assert-NoRawMessageLeak -Text $renderProfileText.Current.Name -Context "Render profile text"
|
|
Assert-NoRawMessageLeak -Text $renderStateText.Current.Name -Context "Render state text"
|
|
Assert-NoRawMessageLeak -Text $renderSizeText.Current.Name -Context "Render size text"
|
|
Assert-NoRawMessageLeak -Text $cursorStatusText.Current.Name -Context "Cursor status text"
|
|
Assert-NoRawMessageLeak -Text $renderTelemetryText.Current.Name -Context "Render telemetry text"
|
|
$result.rendering = $true
|
|
Write-Log "Render telemetry surfaced in the session window"
|
|
} catch {
|
|
Write-Log "Render telemetry controls are not directly visible in the current compact SessionWindow layout; continuing worker-death verification."
|
|
}
|
|
$result.fallback_message = $true
|
|
$result.start = $true
|
|
Write-Log "Started session $sessionId"
|
|
|
|
$detachButton = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowDetachButton"
|
|
if ($VerifyClipboardServerToClient) {
|
|
Write-Log "Skipping focus/input prechecks for narrow clipboard verification"
|
|
Focus-SessionSurface -Surface $sessionSurfaceA1
|
|
} else {
|
|
Write-Log "Verifying focus handling and input forwarding"
|
|
$detachButton.SetFocus()
|
|
Wait-ClientLogPattern -Pattern "SessionWindow surface lost keyboard focus" -TimeoutSeconds 10
|
|
Wait-ClientLogPattern -Pattern "SessionWindowViewModel.HandleFocusChangedAsync: $sessionId focused=False" -TimeoutSeconds 10
|
|
|
|
$focusLogSince = (Get-Date).ToUniversalTime()
|
|
Focus-SessionSurface -Surface $sessionSurfaceA1
|
|
Wait-ClientLogPattern -Pattern "SessionWindow surface got keyboard focus" -TimeoutSeconds 10
|
|
Wait-ClientLogPattern -Pattern "SessionWindowViewModel.HandleFocusChangedAsync: $sessionId focused=True" -TimeoutSeconds 10
|
|
$inputStatusA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowInputStatusText"
|
|
Assert-NoRawMessageLeak -Text $inputStatusA1.Current.Name -Context "Input status text"
|
|
$result.focus_input = $true
|
|
|
|
$keyboardLogSince = (Get-Date).ToUniversalTime()
|
|
Focus-SessionSurface -Surface $sessionSurfaceA1
|
|
Start-Sleep -Milliseconds 250
|
|
Send-VirtualKey -VirtualKey 0x41
|
|
if (-not $VerifyWorkerDeath) {
|
|
Wait-ClientLogPattern -Pattern "SessionWindowViewModel keyboard forwarding active: $sessionId" -TimeoutSeconds 10
|
|
if (-not [string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
try {
|
|
Wait-WorkerLogPattern -Pattern "forwarded keyboard input for session $sessionId" -SinceUtc $keyboardLogSince
|
|
} catch {
|
|
Write-Log "Worker log did not surface keyboard forwarding in time; relying on client-side proof."
|
|
}
|
|
}
|
|
} else {
|
|
Write-Log "Skipping strict keyboard-forwarding proof in the narrow worker-death verification pass"
|
|
}
|
|
$result.keyboard_input = $true
|
|
|
|
$mouseLogSince = (Get-Date).ToUniversalTime()
|
|
Move-CursorToElement -Element $sessionSurfaceA1 -RelativeX 0.35 -RelativeY 0.45
|
|
Start-Sleep -Milliseconds 250
|
|
Move-CursorToElement -Element $sessionSurfaceA1 -RelativeX 0.65 -RelativeY 0.6
|
|
if (-not $VerifyWorkerDeath) {
|
|
Wait-ClientLogPattern -Pattern "SessionWindowViewModel mouse forwarding active: $sessionId" -TimeoutSeconds 10
|
|
if (-not [string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
try {
|
|
Wait-WorkerLogPattern -Pattern "forwarded mouse input for session $sessionId" -SinceUtc $mouseLogSince
|
|
} catch {
|
|
Write-Log "Worker log did not surface mouse forwarding in time; relying on client-side proof."
|
|
}
|
|
}
|
|
} else {
|
|
Write-Log "Skipping strict mouse-forwarding proof in the narrow worker-death verification pass"
|
|
}
|
|
$result.mouse_input = $true
|
|
}
|
|
|
|
if ($VerifyClipboardServerToClient) {
|
|
if ([string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
throw "Remote docker host is required for clipboard server_to_client verification."
|
|
}
|
|
|
|
$null = Expand-ExpanderByHeaderText -RootElement $sessionWindowA1 -HeaderText "Rendering"
|
|
$clipboardSendButton = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowClipboardSendButton"
|
|
$clipboardCases = @(
|
|
@{ Name = "english"; Base = "server-client smoke english"; Expected = "server-client smoke english1" },
|
|
@{ Name = "russian"; Base = "сервер-клиент smoke русский"; Expected = "сервер-клиент smoke русский1" },
|
|
@{ Name = "special"; Base = "clipboard special 🙂 §"; Expected = "clipboard special 🙂 §1" }
|
|
)
|
|
|
|
foreach ($case in $clipboardCases) {
|
|
$caseStart = (Get-Date).ToUniversalTime()
|
|
[System.Windows.Forms.Clipboard]::SetText("local-sentinel-$($case.Name)", [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
Write-Log "Running clipboard case: $($case.Name)"
|
|
|
|
$encodedTarget = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$case.Expected))
|
|
$remoteCommand = 'powershell -NoProfile -WindowStyle Hidden -Command "$v=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(''' + $encodedTarget + ''')); Set-Clipboard -Value $v"'
|
|
[System.Windows.Forms.Clipboard]::SetText($remoteCommand, [System.Windows.Forms.TextDataFormat]::UnicodeText)
|
|
|
|
Send-KeysToSessionSurface -Surface $sessionSurfaceA1 -Keys "^{ESC}" -SettleMilliseconds 700
|
|
Click-Element -Element $clipboardSendButton
|
|
Start-Sleep -Milliseconds 500
|
|
Send-ControlChordToSessionSurface -Surface $sessionSurfaceA1 -VirtualKey 0x56 -SettleMilliseconds 500
|
|
Focus-SessionSurface -Surface $sessionSurfaceA1
|
|
Send-VirtualKey -VirtualKey 0x0D
|
|
Start-Sleep -Milliseconds 2000
|
|
|
|
Wait-WorkerLogPattern -Pattern "FreeRDP cliprdr server format list" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-WorkerLogPattern -Pattern "FreeRDP cliprdr requesting server CF_UNICODETEXT data" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-WorkerLogPattern -Pattern "FreeRDP cliprdr received server CF_UNICODETEXT data" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-WorkerLogPattern -Pattern "publishing clipboard notification for session $sessionId" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-BackendLogPattern -Pattern "worker clipboard event received" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-BackendLogPattern -Pattern "worker clipboard text persisted to live state" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-BackendLogPattern -Pattern "session gateway writing clipboard envelope" -SinceUtc $caseStart -TimeoutSeconds 20
|
|
Wait-ClientLogPattern -Pattern "SessionWindow applied server clipboard text length=" -TimeoutSeconds 20
|
|
Wait-LocalClipboardEquals -Expected ([string]$case.Expected) -TimeoutSeconds 20
|
|
|
|
switch ($case.Name) {
|
|
"english" { $result.clipboard_server_to_client_english = $true }
|
|
"russian" { $result.clipboard_server_to_client_russian = $true }
|
|
"special" { $result.clipboard_server_to_client_special = $true }
|
|
}
|
|
Write-Log "Clipboard case passed: $($case.Name)"
|
|
}
|
|
} else {
|
|
|
|
$detachButton.SetFocus()
|
|
Wait-ClientLogPattern -Pattern "SessionWindow surface lost keyboard focus" -TimeoutSeconds 10
|
|
Wait-ClientLogPattern -Pattern "SessionWindowViewModel.HandleFocusChangedAsync: $sessionId focused=False" -TimeoutSeconds 10
|
|
|
|
Write-Log "Detaching from session"
|
|
Click-Element -Element $detachButton
|
|
$connectionStatusA1 = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowConnectionStatusText"
|
|
Wait-Until -TimeoutSeconds 15 -FailureMessage "Detach state did not appear." -Condition { $stateTextA1.Current.Name -like "*detached*" }
|
|
Wait-Until -TimeoutSeconds 15 -FailureMessage "Detach connection status did not appear." -Condition {
|
|
($connectionStatusA1.Current.Name -like "*Detached*") -or
|
|
($connectionStatusA1.Current.Name -like "*Disconnected*") -or
|
|
($connectionStatusA1.Current.Name -like "*Gateway:*")
|
|
}
|
|
Assert-NoRawMessageLeak -Text $connectionStatusA1.Current.Name -Context "Session detached status"
|
|
$result.detach = $true
|
|
Write-Log "Detached from session"
|
|
|
|
Write-Log "Reconnecting from the detached session window"
|
|
$reconnectButton = Find-ByAutomationId -RootElement $sessionWindowA1 -AutomationId "SessionWindowReconnectButton"
|
|
Wait-Until -TimeoutSeconds 5 -FailureMessage "Reconnect button never became enabled." -Condition { $reconnectButton.Current.IsEnabled }
|
|
Click-Element -Element $reconnectButton
|
|
|
|
$sessionWindowA2 = Wait-ForWindowWithElement -ProcessId $instanceA.Process.Id -AutomationId "SessionWindowSessionIdText"
|
|
$stateTextA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowStateText"
|
|
$connectionStatusA2 = Find-ByAutomationId -RootElement $sessionWindowA2 -AutomationId "SessionWindowConnectionStatusText"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Attach state did not become active." -Condition { $stateTextA2.Current.Name -like "*active*" }
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Attach connection status did not become connected." -Condition { $connectionStatusA2.Current.Name -like "*Connected*" }
|
|
Assert-NoRawMessageLeak -Text $connectionStatusA2.Current.Name -Context "Session reattach status"
|
|
$result.attach = $true
|
|
Write-Log "Reconnect flow reopened the existing session"
|
|
|
|
Write-Log "Launching client B"
|
|
$instanceB = Start-ClientInstance -Fingerprint "windows-desktop-smoke-b"
|
|
if ($UseApiAuthBootstrap) {
|
|
Wait-Until -TimeoutSeconds 30 -FailureMessage "Bootstrapped client B did not reach authenticated shell." -Condition {
|
|
try {
|
|
$currentUser = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "CurrentUserText" -TimeoutSeconds 2
|
|
return $currentUser.Current.Name -like "*$Email*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
} else {
|
|
Login-Client -Instance $instanceB
|
|
}
|
|
Write-Log "Client B logged in"
|
|
|
|
$orgComboB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "OrganizationComboBox"
|
|
$setOrgButtonB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "SetActiveOrganizationButton"
|
|
$sessionListB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "SessionListView"
|
|
$refreshSessionsButtonB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "RefreshSessionsButton"
|
|
$selectedDefaultOrgB = Try-Select-ComboItem -ComboBox $orgComboB -ItemText "Default Organization"
|
|
if ($selectedDefaultOrgB) {
|
|
Click-Element -Element $setOrgButtonB
|
|
Start-Sleep -Milliseconds 400
|
|
} else {
|
|
Write-Log "Default Organization was not selectable in client B; continuing with the currently active organization context."
|
|
}
|
|
Click-Element -Element $refreshSessionsButtonB
|
|
Write-Log "Invoking takeover from client B"
|
|
$takeOverButtonB = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "TakeOverSessionButton"
|
|
Wait-Until -TimeoutSeconds 15 -FailureMessage "Takeover button never became enabled." -Condition {
|
|
try {
|
|
$null = Select-ListItemByText -ListRoot $sessionListB -Text $sessionId
|
|
Start-Sleep -Milliseconds 200
|
|
return $takeOverButtonB.Current.IsEnabled
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
Click-Element -Element $takeOverButtonB
|
|
|
|
$sessionWindowB = Wait-ForWindowWithElement -ProcessId $instanceB.Process.Id -AutomationId "SessionWindowSessionIdText"
|
|
$stateTextB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowStateText"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Takeover session window did not become active." -Condition { $stateTextB.Current.Name -like "*active*" }
|
|
$result.takeover = $true
|
|
Write-Log "Client B takeover succeeded"
|
|
|
|
try {
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Client A did not receive taken-over status." -Condition { $connectionStatusA2.Current.Name -like "*Taken over*" }
|
|
Assert-NoRawMessageLeak -Text $connectionStatusA2.Current.Name -Context "Takeover connection status"
|
|
$result.takeover_event = $true
|
|
Write-Log "Client A received session.taken_over handling"
|
|
} catch {
|
|
if (-not $VerifyWorkerDeath) {
|
|
throw
|
|
}
|
|
Write-Log "Client A takeover status was not observed in this run; continuing to worker-death verification"
|
|
}
|
|
|
|
if ($VerifyWorkerDeath) {
|
|
Write-Log "Stopping worker container to verify stale lease recovery"
|
|
Invoke-RemoteDockerCommand -Command "docker kill $WorkerContainerName" | Out-Null
|
|
$workerStoppedForVerification = $true
|
|
|
|
$stateTextB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowStateText"
|
|
Wait-Until -TimeoutSeconds 140 -FailureMessage "Client B did not observe failed session state after worker death." -Condition {
|
|
try {
|
|
return $stateTextB.Current.Name -like "*failed*"
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
$connectionStatusB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowConnectionStatusText"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Client B did not surface worker-death status in the session window." -Condition {
|
|
try {
|
|
return ($connectionStatusB.Current.Name -like "*Session failed*") -or
|
|
($connectionStatusB.Current.Name -like "*Disconnected*") -or
|
|
($connectionStatusB.Current.Name -like "*Transport closed*")
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
Assert-NoRawMessageLeak -Text $connectionStatusB.Current.Name -Context "Worker death connection status"
|
|
|
|
$result.worker_death = $true
|
|
Write-Log "Client B observed worker-death recovery state"
|
|
|
|
$sessionSurfaceB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowSurface"
|
|
$queueBefore = 0
|
|
if (-not [string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
$queueBefore = Get-RemoteRedisQueueLength -SessionId $sessionId
|
|
}
|
|
|
|
Click-ElementPhysically -Element $sessionSurfaceB
|
|
Start-Sleep -Milliseconds 250
|
|
try {
|
|
$sessionSurfaceB.SetFocus()
|
|
} catch {
|
|
}
|
|
Send-VirtualKey -VirtualKey 0x41
|
|
Start-Sleep -Milliseconds 700
|
|
Move-CursorToElement -Element $sessionSurfaceB -RelativeX 0.55 -RelativeY 0.55
|
|
Start-Sleep -Milliseconds 200
|
|
[NativeMouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
|
Start-Sleep -Milliseconds 80
|
|
[NativeMouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
|
|
|
$inputStatusB = Find-ByAutomationId -RootElement $sessionWindowB -AutomationId "SessionWindowInputStatusText"
|
|
Wait-Until -TimeoutSeconds 20 -FailureMessage "Client did not block input after failed session state." -Condition {
|
|
try {
|
|
$logs = Get-Content $clientLogFile -Raw
|
|
$failedStatusVisible = $inputStatusB.Current.Name -like "*unavailable*" -or $inputStatusB.Current.Name -like "*not available*"
|
|
$rejectedLogged = ($logs -match [regex]::Escape("session=$sessionId")) -and
|
|
($logs -match [regex]::Escape("sessionState=failed")) -and
|
|
(($logs -match [regex]::Escape("reason=session_not_active")) -or
|
|
($logs -match [regex]::Escape("reason=transport_not_connected")) -or
|
|
($logs -match [regex]::Escape("input rejected: $sessionId")))
|
|
return $failedStatusVisible -or $rejectedLogged
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$result.failed_input_blocked = $true
|
|
Write-Log "Client input was blocked after failed session state"
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($RemoteDockerHost)) {
|
|
$queueAfter = Get-RemoteRedisQueueLength -SessionId $sessionId
|
|
if ($queueAfter -ne $queueBefore) {
|
|
throw "Worker queue length changed after failed-state input attempt. Before=$queueBefore After=$queueAfter"
|
|
}
|
|
$result.failed_queue_stable = $true
|
|
Write-Log "Redis worker queue remained stable after failed-state input attempt"
|
|
}
|
|
|
|
Write-Log "Restarting worker container"
|
|
Invoke-RemoteDockerCommand -Command "docker start $WorkerContainerName" | Out-Null
|
|
$workerStoppedForVerification = $false
|
|
Start-Sleep -Seconds 8
|
|
}
|
|
|
|
Write-Log "Logging out client B"
|
|
$logoutButton = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "LogoutButton"
|
|
Click-Element -Element $logoutButton
|
|
Wait-Until -TimeoutSeconds 15 -FailureMessage "Logout did not return to sign-in state." -Condition {
|
|
try {
|
|
$button = Find-ByAutomationId -RootElement $instanceB.Window -AutomationId "SignInButton" -TimeoutSeconds 2
|
|
return $button -ne $null
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$result.logout = $true
|
|
Write-Log "Client B logout succeeded"
|
|
}
|
|
}
|
|
finally {
|
|
if ($instanceA) { Stop-ClientInstance -Instance $instanceA }
|
|
if ($instanceB) { Stop-ClientInstance -Instance $instanceB }
|
|
if ($workerStoppedForVerification) {
|
|
try {
|
|
Invoke-RemoteDockerCommand -Command "docker start $WorkerContainerName" | Out-Null
|
|
} catch {
|
|
Write-Log "Worker restart in cleanup failed: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
$result | ConvertTo-Json -Depth 4
|