Files
rdp-proxy/scripts/windows-smoke/desktop-smoke.ps1
T
2026-04-28 22:29:50 +03:00

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