diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 3499ceee..5e82c303 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -195,6 +195,26 @@ try { if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { throw "RED: Windows shell validation does not execute the UI Automation live gate" } + $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or + $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x0101, \(UIntPtr\)key, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch '\[DllImport\("kernel32\.dll"\)\]\s*private static extern uint GetCurrentThreadId' -or + $uiaLiveGateSource -notmatch 'IsForegroundWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch 'UIA_FOCUS_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch '(?s)function Hide-TestProviderZmxWindows.*?\[string\]\$_\.ExecutablePath\)\s+-eq\s+\$providerZmx.*?HideProcessWindows.*?HideWindow\(\$foreground\)' -or + $uiaLiveGateSource -notmatch 'function Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Test-FocusedElementIdentity \$candidate \$element \$expectedAutomationId' -or + $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch '\$element\.SetFocus\(\)' -or + $uiaLiveGateSource -notmatch 'Retain-FocusWithRetry \$shellWindow \$safeFocusRow \$safeRowId "before-retention"' -or + $uiaLiveGateSource -notmatch '\$backendFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings backend control could not retain foreground focus' -or + $uiaLiveGateSource -notmatch '\$cancelFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings model control could not retain foreground focus') { + throw "RED: UIA live gate does not prove foreground ownership before accepting row focus" + } $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { throw "RED: Windows shell validation does not run the native updater tests" diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index b57d400c..2513ae03 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -104,6 +104,24 @@ public static class GraphCodeUiaGateState { private static extern IntPtr GetFocus(); [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + private static extern bool BringWindowToTop(IntPtr window); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowTextLength(IntPtr window); + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr window, int command); + [DllImport("user32.dll")] + private static extern IntPtr SetActiveWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); public static IntPtr FindChild(IntPtr parent, string className) { return FindWindowEx(parent, IntPtr.Zero, className, null); } @@ -143,7 +161,8 @@ public static class GraphCodeUiaGateState { return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); } public static bool PostKeyboard(IntPtr window, uint key) { - return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero); + return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); } public static bool SendReturn(IntPtr window) { if (window == IntPtr.Zero) return false; @@ -161,12 +180,78 @@ public static class GraphCodeUiaGateState { } public static bool FocusControl(IntPtr parent, IntPtr control) { if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; - SetForegroundWindow(parent); - SetFocus(control); - return GetFocus() == control; + ActivateWindow(parent); + uint parentThread = GetWindowThreadProcessId(parent, out _); + uint currentThread = GetCurrentThreadId(); + bool attached = currentThread != parentThread && + AttachThreadInput(currentThread, parentThread, true); + try { + SetFocus(control); + return IsForegroundWindow(parent) && GetFocus() == control; + } finally { + if (attached) AttachThreadInput(currentThread, parentThread, false); + } } public static bool ActivateWindow(IntPtr window) { - return window != IntPtr.Zero && SetForegroundWindow(window); + if (window == IntPtr.Zero) return false; + IntPtr foreground = GetForegroundWindow(); + uint foregroundThread = foreground == IntPtr.Zero ? 0 : + GetWindowThreadProcessId(foreground, out _); + uint targetThread = GetWindowThreadProcessId(window, out _); + uint currentThread = GetCurrentThreadId(); + bool attachForeground = foregroundThread != 0 && + currentThread != foregroundThread && + AttachThreadInput(currentThread, foregroundThread, true); + bool attachTarget = currentThread != targetThread && + AttachThreadInput(currentThread, targetThread, true); + try { + ShowWindow(window, 9); + BringWindowToTop(window); + keybd_event(0x12, 0, 0, UIntPtr.Zero); + keybd_event(0x12, 0, 0x0002, UIntPtr.Zero); + SetActiveWindow(window); + SetForegroundWindow(window); + return IsForegroundWindow(window); + } finally { + if (attachTarget) AttachThreadInput(currentThread, targetThread, false); + if (attachForeground) AttachThreadInput(currentThread, foregroundThread, false); + } + } + public static bool IsForegroundWindow(IntPtr window) { + return window != IntPtr.Zero && GetForegroundWindow() == window; + } + public static IntPtr CurrentForegroundWindow() { + return GetForegroundWindow(); + } + public static uint WindowProcessId(IntPtr window) { + if (window == IntPtr.Zero) return 0; + uint processId; + GetWindowThreadProcessId(window, out processId); + return processId; + } + public static string WindowClass(IntPtr window) { + if (window == IntPtr.Zero) return ""; + var text = new StringBuilder(256); + GetClassName(window, text, text.Capacity); + return text.ToString(); + } + public static string WindowTitle(IntPtr window) { + if (window == IntPtr.Zero) return ""; + int length = GetWindowTextLength(window); + var text = new StringBuilder(length + 1); + GetWindowText(window, text, text.Capacity); + return text.ToString(); + } + public static void HideWindow(IntPtr window) { + if (window != IntPtr.Zero) ShowWindow(window, 0); + } + public static void HideProcessWindows(uint processId) { + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId) HideWindow(window); + return true; + }, IntPtr.Zero); } public static bool PostMouseClick(IntPtr window) { return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); @@ -194,6 +279,118 @@ function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } +function Format-WindowHandle([IntPtr] $handle) { + return "0x$($handle.ToInt64().ToString('x'))" +} + +function Format-AutomationElement( + [System.Windows.Automation.AutomationElement] $element +) { + if ($null -eq $element) { + return "unavailable" + } + try { + return "$($element.Current.AutomationId):$($element.Current.Name)" + } catch { + return "unavailable:$($_.Exception.Message)" + } +} + +function Get-FocusDiagnostics([IntPtr] $expectedWindow) { + $foreground = [GraphCodeUiaGateState]::CurrentForegroundWindow() + $foregroundProcessId = [GraphCodeUiaGateState]::WindowProcessId($foreground) + $foregroundProcess = if ($foregroundProcessId -ne 0) { + Get-Process -Id $foregroundProcessId -ErrorAction SilentlyContinue + } else { + $null + } + $focusedDescription = "unavailable" + try { + $focusedElement = [System.Windows.Automation.AutomationElement]::FocusedElement + $focusedDescription = "automationId='$($focusedElement.Current.AutomationId)' name='$($focusedElement.Current.Name)' processId=$($focusedElement.Current.ProcessId)" + } catch { + $focusedDescription = "error='$($_.Exception.Message)'" + } + return "foreground=$(Format-WindowHandle $foreground) expected=$(Format-WindowHandle $expectedWindow) expectedIsForeground=$([GraphCodeUiaGateState]::IsForegroundWindow($expectedWindow)) foregroundPid=$foregroundProcessId foregroundProcess='$($foregroundProcess.ProcessName)' foregroundClass='$([GraphCodeUiaGateState]::WindowClass($foreground))' foregroundTitle='$([GraphCodeUiaGateState]::WindowTitle($foreground))' focused={$focusedDescription}" +} + +function Hide-TestProviderZmxWindows { + if (-not $env:GRAPHCODE_ZMX) { return } + $providerZmx = [IO.Path]::GetFullPath($env:GRAPHCODE_ZMX) + foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match "(?i)^zmx(?:\.exe)?$" -and + (([string]$_.ExecutablePath) -eq $providerZmx -or + ([string]$_.CommandLine) -like "*$providerZmx*") + })) { + [GraphCodeUiaGateState]::HideProcessWindows([uint32]$process.ProcessId) + } + $foreground = [GraphCodeUiaGateState]::CurrentForegroundWindow() + $foregroundTitle = [GraphCodeUiaGateState]::WindowTitle($foreground) + if ($foregroundTitle -eq $providerZmx -or + $foregroundTitle -like "*\zmx.exe") { + [GraphCodeUiaGateState]::HideWindow($foreground) + } +} + +function Test-FocusedElementIdentity( + [System.Windows.Automation.AutomationElement] $candidate, + [System.Windows.Automation.AutomationElement] $expected, + [string] $expectedAutomationId +) { + if ($null -eq $candidate -or $null -eq $expected) { return $false } + try { + if ($expectedAutomationId -and + $candidate.Current.AutomationId -eq $expectedAutomationId) { + return $true + } + if ($expected.Current.NativeWindowHandle -ne 0 -and + $candidate.Current.NativeWindowHandle -eq $expected.Current.NativeWindowHandle) { + return $true + } + return (Get-RuntimeIdentity $candidate) -eq (Get-RuntimeIdentity $expected) + } catch { + return $false + } +} + +function Retain-FocusWithRetry( + [IntPtr] $window, + [System.Windows.Automation.AutomationElement] $element, + [string] $expectedAutomationId, + [string] $label, + [int] $Attempts = 600 +) { + $candidate = $null + Hide-TestProviderZmxWindows + Start-Sleep -Milliseconds 250 + Write-Host "UIA_FOCUS_DIAGNOSTICS phase=$label $(Get-FocusDiagnostics $window)" + for ($index = 0; $index -lt $Attempts; $index++) { + Hide-TestProviderZmxWindows + $activated = [GraphCodeUiaGateState]::ActivateWindow($window) + Start-Sleep -Milliseconds 50 + try { + $element.SetFocus() + } catch { + $null = [GraphCodeUiaGateState]::FocusControl( + $window, [IntPtr]$element.Current.NativeWindowHandle + ) + } + Start-Sleep -Milliseconds 50 + try { + $candidate = [System.Windows.Automation.AutomationElement]::FocusedElement + } catch { + $candidate = $null + } + if ($activated -and + [GraphCodeUiaGateState]::IsForegroundWindow($window) -and + (Test-FocusedElementIdentity $candidate $element $expectedAutomationId)) { + return [pscustomobject]@{ Focused = $candidate; Candidate = $candidate } + } + } + return [pscustomobject]@{ Focused = $null; Candidate = $candidate } +} + function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker @@ -995,22 +1192,9 @@ try { Require ($null -ne $currentSafe) "safe worktree row disappeared before focus: $(@($currentRowsBeforeFocus | ForEach-Object { $_.Current.AutomationId }) -join ',')" Require ($currentSafe.Current.AutomationId -eq $safeRowId) "safe worktree identity changed before focus: $safeRowId -> $($currentSafe.Current.AutomationId)" Require ($safeFocusRow.Current.Name -eq "C:\fixture-safe") "safe worktree provider became unavailable before focus" - $focused = $null - # Widened from 300x50ms (15s) alongside the earlier workspace-collapse retry loop: this - # assertion has been observed to flake under heavy CI-runner load with the identical passing - # binary/commit (confirmed via repeated same-commit reruns), not from a code regression. Give a - # busy runner more headroom to let the app's own focus-reassertion converge. - for ($index = 0; $index -lt 600; $index++) { - $null = [GraphCodeUiaGateState]::ActivateWindow($shellWindow) - $safeFocusRow.SetFocus() - Start-Sleep -Milliseconds 50 - $candidate = [System.Windows.Automation.AutomationElement]::FocusedElement - if ($candidate.Current.AutomationId -eq $safeRowId) { - $focused = $candidate - break - } - } - Require ($null -ne $focused) "worktree row could not retain focus against concurrent desktop focus changes; focused=$($candidate.Current.AutomationId):$($candidate.Current.Name)" + $focusResult = Retain-FocusWithRetry $shellWindow $safeFocusRow $safeRowId "before-retention" + $focused = $focusResult.Focused + Require ($null -ne $focused) "worktree row could not retain focus against concurrent desktop focus changes; focused=$(Format-AutomationElement $focusResult.Candidate); $(Get-FocusDiagnostics $shellWindow)" Require ($focused.Current.AutomationId -eq $safeRowId) "focus source identity was '$($focused.Current.AutomationId)', expected '$safeRowId'" Require ((Get-RuntimeIdentity $focused) -eq (Get-RuntimeIdentity $safeFocusRow)) "focus runtime identity changed" for ($index = 0; $index -lt 20 -and -not [GraphCodeUiaGateState]::FocusObserved; $index++) { @@ -1669,9 +1853,10 @@ try { Require ($null -ne $backendButton) "Product Settings backend control became unavailable" Require ([GraphCodeUiaGateState]::SendCommand($settingsWindow, 6112)) ` "Product Settings backend fixture mutation was rejected" - $null = [GraphCodeUiaGateState]::FocusControl( - $settingsWindow, [IntPtr]$backendButton.Current.NativeWindowHandle - ) + $backendFocus = Retain-FocusWithRetry ` + $settingsWindow $backendButton $backendButton.Current.AutomationId ` + "product-settings-return" + Require ($null -ne $backendFocus.Focused) "Product Settings backend control could not retain foreground focus; focused=$(Format-AutomationElement $backendFocus.Candidate); $(Get-FocusDiagnostics $settingsWindow)" Require ([GraphCodeUiaGateState]::PostKeyboard( [IntPtr]$backendButton.Current.NativeWindowHandle, 0x0D )) "Product Settings focused control rejected Return" @@ -1712,9 +1897,10 @@ try { )) ) Require ($null -ne $cancelModel) "Product Settings omitted its model picker on reopen" - $null = [GraphCodeUiaGateState]::FocusControl( - $cancelWindow, [IntPtr]$cancelModel.Current.NativeWindowHandle - ) + $cancelFocus = Retain-FocusWithRetry ` + $cancelWindow $cancelModel $cancelModel.Current.AutomationId ` + "product-settings-escape" + Require ($null -ne $cancelFocus.Focused) "Product Settings model control could not retain foreground focus; focused=$(Format-AutomationElement $cancelFocus.Candidate); $(Get-FocusDiagnostics $cancelWindow)" Require ([GraphCodeUiaGateState]::PostKeyboard( [IntPtr]$cancelModel.Current.NativeWindowHandle, 0x1B )) "Product Settings focused control rejected Escape"