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), '') 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 '^\[(?[^\]]+)\]') { 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