diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 309f0d0c..80d656be 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -716,9 +716,92 @@ function Invoke-CapSceneAction { Assert-CapWorkerDeadline "after action $Action" } +function Wait-CapMatchedWireMode { + param( + [Parameter(Mandatory)]$AppRun, + [Parameter(Mandatory)][DateTime]$DeadlineUtc, + [scriptblock]$StatusProbe, + [scriptblock]$NowProvider, + [scriptblock]$SleepAction + ) + if (-not $StatusProbe -and -not $NowProvider -and -not $SleepAction) { + return Wait-CapAutomationElement -Window $AppRun.Window ` + -ProcessIdentity $AppRun.Run.Identity ` + -Name 'Choose a target loop to wire this entry' -AutomationId 'status' ` + -DeadlineUtc $DeadlineUtc ` + -BeforePoll {Assert-CapWorkerDeadline 'Wire mode status'} ` + -FindElement { + param([ref]$Snapshot) + Find-CapAutomationElement -RootHandle $AppRun.Window ` + -ProcessIdentity $AppRun.Run.Identity ` + -ExpectedProcessId $AppRun.Run.Identity.Pid ` + -Name 'Choose a target loop to wire this entry' -AutomationId 'status' ` + -RefusalSnapshot $Snapshot + } + } + $now={ + if ($NowProvider) {return & $NowProvider} + return [DateTime]::UtcNow + } + $sleep={ + param([int]$Milliseconds) + if ($SleepAction) {& $SleepAction $Milliseconds;return} + Start-Sleep -Milliseconds $Milliseconds + } + while ((& $now) -lt $DeadlineUtc) { + Assert-CapWorkerDeadline 'Wire mode status' + $status=if ($StatusProbe) { + & $StatusProbe $AppRun + } else { + Find-CapAutomationElement -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity ` + -ExpectedProcessId $AppRun.Run.Identity.Pid -Name 'Choose a target loop to wire this entry' ` + -AutomationId 'status' + } + if ($null -ne $status -and + $status.Current.Name -ceq 'Choose a target loop to wire this entry' -and + $status.Current.AutomationId -ceq 'status') { + return $status + } + & $sleep 50 + } + throw 'Wire mode status did not appear before the action deadline.' +} + function Send-CapMatchedEscape { - param([Parameter(Mandatory)]$AppRun) - return [GraphCodeUiaGateState]::PostKeyboard($AppRun.Window,0x1B) + param( + [Parameter(Mandatory)]$AppRun, + [scriptblock]$ForegroundProbe, + [scriptblock]$DialogProbe, + [scriptblock]$KeyboardSender + ) + if (-not $ForegroundProbe -and -not $DialogProbe -and -not $KeyboardSender) { + $null=Assert-CapRootProcess -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity + } + $foreground=if ($ForegroundProbe) { + [bool](& $ForegroundProbe $AppRun.Window) + } else { + [GraphCodeUiaGateState]::IsForegroundWindow($AppRun.Window) + } + if (-not $foreground) { + throw 'Wire cancellation requires the owned GraphCode shell foreground.' + } + $dialog=if ($DialogProbe) { + [IntPtr](& $DialogProbe $AppRun.Run.Identity.Pid) + } else { + [GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$AppRun.Run.Identity.Pid) + } + if ($dialog -ne [IntPtr]::Zero) { + throw 'Wire cancellation refuses to inject Escape while an owned native dialog is present.' + } + $sent=if ($KeyboardSender) { + [bool](& $KeyboardSender $AppRun.Window 0x1B) + } else { + [GraphCodeUiaGateState]::PostKeyboard($AppRun.Window,0x1B) + } + if (-not $sent) { + throw 'Wire cancellation Escape injection was rejected.' + } + return $true } function Measure-CapSceneZoom { @@ -2249,10 +2332,8 @@ function Invoke-CapMatchedScenes { $markReceipt=$null if ($runWire) { $null=Invoke-CapSceneAction -AppRun $app -Action 'wire-node' -SubjectId $recovery.wireIdentity - $wireStatus=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` - -ExpectedProcessId $app.Run.Identity.Pid -Name 'Choose a target loop to wire this entry' ` - -AutomationId 'status' - if ($null -eq $wireStatus) {throw "Wire it up did not expose its armed result for $scene."} + $wireStatus=Wait-CapMatchedWireMode -AppRun $app ` + -DeadlineUtc (Get-CapActionDeadline -WorkerDeadlineUtc $leaseDeadlineUtc) if (-not (Send-CapMatchedEscape -AppRun $app)) { throw "Wire it up could not be cancelled after matched evidence for $scene." } diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 606dfecd..bad61bd9 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -716,6 +716,7 @@ function Assert-CapRejects { 'GraphCodeUiaGateState|PostMessage|user32.dll|PostMessageA', 'GraphCodeUiaGateState|EnumWindows|user32.dll|EnumWindows', 'GraphCodeUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', + 'GraphCodeUiaGateState|GetForegroundWindow|user32.dll|GetForegroundWindow', 'GraphCodeUiaGateState|GetClassName|user32.dll|GetClassNameW', 'CapNative|NtQueryInformationProcess|ntdll.dll|NtQueryInformationProcess', 'CapNative|NtQueryInformationProcess|ntdll.dll|NtQueryInformationProcess', @@ -751,7 +752,7 @@ function Assert-CapRejects { 'CapProcessJob|SetHandleInformation|kernel32.dll|SetHandleInformation' ) $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) - Assert-CapTest ($actual.Count -eq 36 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static36 export contract differs.' + Assert-CapTest ($actual.Count -eq 37 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static37 export contract differs.' return @{ subcases = 36; evidence = 'API export resolution only, no native window or GUI action.' } } Invoke-CapTest 'legacy mutable seed journal proof is refused and exact numbered terminal proof is accepted' { @@ -3003,6 +3004,8 @@ function Invoke-CapMatchedContracts { $workerSource.Contains('Read-CapMatchedChromeControls -AppRun $app') -and $workerSource.Contains('function Get-CapWorkerExpectedImageCount') -and $workerSource.Contains('$images.Count -eq (Get-CapWorkerExpectedImageCount)') -and + $workerSource.Contains('function Wait-CapMatchedWireMode') -and + $workerSource.Contains('[GraphCodeUiaGateState]::PostKeyboard') -and $workerSource.Contains("-Action 'attention-action'") -and $workerSource.Contains("fixtureIsolation='ONE_SCENE_ONE_DISPOSABLE_PHASE_ROOT'") -and $prepareSource.Contains("[switch]`$PlanOnly") -and @@ -3045,6 +3048,7 @@ function Invoke-CapMatchedContracts { names=$names;focused=$plan;postFixFocused=$postFixPlan;focusedMutations=$focusedMutations prelaunchPaths=(Invoke-CapFocusedPrelaunchPathContract) expectedImageCounts=(Invoke-CapFocusedExpectedImageCountContract) + wireCancellation=(Invoke-CapMatchedWireCancellationContract) routing=(Invoke-CapMatchedSceneRoutingContract)} } Invoke-CapTest 'matched tab observations preserve exact owned IDs parents selection and close bounds' { @@ -4223,6 +4227,11 @@ function Invoke-CapMatchedSceneRoutingContract { } return $rows.ToArray() } + function Wait-CapMatchedWireMode { + param($AppRun,$DeadlineUtc) + if (-not $model.wireArmed) {throw 'Wire mode was not armed before cancellation.'} + return (New-CapMockElement 'status' 'Choose a target loop to wire this entry' 4242 @(0,880,1200,900)) + } function Send-CapMatchedEscape {param($AppRun) $model.wireArmed=$false;return $true} function Read-CapMatchedRows { param($AppRun) @@ -4365,6 +4374,88 @@ function Invoke-CapMatchedSceneRoutingContract { } $selectionFunction.Extent.Text $function.Extent.Text } +function Invoke-CapMatchedWireCancellationContract { + $ast=[Management.Automation.Language.Parser]::ParseFile($workerPath,[ref]$null,[ref]$null) + $waitFunction=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Wait-CapMatchedWireMode'},$true) + $cancelFunction=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Send-CapMatchedEscape'},$true) + Assert-CapTest ($null -ne $waitFunction -and $null -ne $cancelFunction) ` + 'Canonical matched Wire cancellation helpers are missing.' + & { + param($WaitDefinition,$CancelDefinition) + . ([scriptblock]::Create($WaitDefinition)) + . ([scriptblock]::Create($CancelDefinition)) + function Assert-CapWorkerDeadline {param($Label)} + $app=[pscustomobject]@{ + Window=[IntPtr]123 + Run=[pscustomobject]@{Identity=[pscustomobject]@{Pid=4242}} + } + $script:clock=[DateTime]::new(2026,10,28,12,0,0,[DateTimeKind]::Utc) + $script:polls=0 + $status=Wait-CapMatchedWireMode -AppRun $app -DeadlineUtc $script:clock.AddSeconds(1) ` + -StatusProbe { + param($AppRun) + $script:polls++ + if ($script:polls -ge 2) { + return [pscustomobject]@{Current=[pscustomobject]@{ + Name='Choose a target loop to wire this entry';AutomationId='status' + }} + } + return $null + } -NowProvider { + $script:clock=$script:clock.AddMilliseconds(10) + return $script:clock + } -SleepAction {param($Milliseconds)} + Assert-CapTest ($status.Current.Name -ceq 'Choose a target loop to wire this entry' -and + $script:polls -eq 2) 'Wire-mode wait did not observe the exact armed state.' + + $script:timeoutClock=[DateTime]::new(2026,10,28,12,0,0,[DateTimeKind]::Utc) + $timeout=Assert-CapRejects { + Wait-CapMatchedWireMode -AppRun $app -DeadlineUtc $script:timeoutClock.AddMilliseconds(20) ` + -StatusProbe {param($AppRun) $null} -NowProvider { + $script:timeoutClock=$script:timeoutClock.AddMilliseconds(10) + return $script:timeoutClock + } -SleepAction {param($Milliseconds)} + } 'Wire mode status did not appear before the action deadline' + + $script:sent=0 + $positive=Send-CapMatchedEscape -AppRun $app ` + -ForegroundProbe {param($Window) $true} ` + -DialogProbe {param($ProcessId) [IntPtr]::Zero} ` + -KeyboardSender {param($Window,$Key) $script:sent++;return $true} + Assert-CapTest ($positive -and $script:sent -eq 1) ` + 'Wire cancellation did not use the canonical Escape sender exactly once.' + $wrongFocus=Assert-CapRejects { + Send-CapMatchedEscape -AppRun $app ` + -ForegroundProbe {param($Window) $false} ` + -DialogProbe {param($ProcessId) [IntPtr]::Zero} ` + -KeyboardSender {param($Window,$Key) $true} + } 'foreground' + $dialog=Assert-CapRejects { + Send-CapMatchedEscape -AppRun $app ` + -ForegroundProbe {param($Window) $true} ` + -DialogProbe {param($ProcessId) [IntPtr]321} ` + -KeyboardSender {param($Window,$Key) $true} + } 'dialog' + $rejected=Assert-CapRejects { + Send-CapMatchedEscape -AppRun $app ` + -ForegroundProbe {param($Window) $true} ` + -DialogProbe {param($ProcessId) [IntPtr]::Zero} ` + -KeyboardSender {param($Window,$Key) $false} + } 'Escape' + return [pscustomobject]@{ + positive=$positive + statusPolls=$script:polls + timeout=$timeout + wrongFocus=$wrongFocus + dialog=$dialog + rejected=$rejected + evidence='ACTUAL_WORKER_FUNCTIONS_SIMULATED_WINDOW_STATE_NO_GUI' + } + } $waitFunction.Extent.Text $cancelFunction.Extent.Text +} + function Invoke-CapFocusedPrelaunchPathContract { $caseRoot=Join-Path $scratch 'focused-prelaunch-paths' $output=Join-Path $caseRoot 'outputs' diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index f04ae907..8bd29f06 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -233,6 +233,9 @@ use separate disposable phase roots: the unselected scene arms and cancels the controls to disappear with the same card identity retained. The selected cycle scene invokes the stable `Inspect` action, returns through `Show in Graph`, and requires the same card, action, reason, and selected identities. +Wire cancellation uses the same native key-down/key-up `PostKeyboard` route as +the live UIA gate, after a bounded exact-status wait, and refuses a non-foreground +shell or an owned native dialog before injecting Escape. This is bounded review evidence, not an automatic parity-ledger promotion. Both profiles require the exact 185-case real-zmx diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 775b4d17..f20c4a3c 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -12,11 +12,9 @@ $script:CapTraceFailures = [Collections.Generic.List[object]]::new() Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Drawing -# Only the required FindTopLevel/PostCommand fragments, not the executable live gate. -# Tools/windows/uia-live-gate.ps1, first Add-Type, FSL-1.1-MIT: -# SHA256 7455148BDCB64A03914812DC415B39EB300293D8970502C1BDFD110044C30795. -# Extracted GraphCodeUiaGateState.cs: -# SHA256 C2514CECBC2637202D6DAE639DF33C77901EB69ADBEDD8CB795C37C8BF92B162. +# Only the required top-level, command, keyboard and foreground fragments, not +# the executable live gate. These stay aligned with the first Add-Type in +# Tools/windows/uia-live-gate.ps1 through focused contracts below. $helperSource = @' using System; using System.Runtime.InteropServices; @@ -29,6 +27,8 @@ public static class GraphCodeUiaGateState { private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); public static IntPtr FindTopLevel(string className, uint processId) { @@ -48,6 +48,13 @@ public static class GraphCodeUiaGateState { public static bool PostCommand(IntPtr window, uint command) { return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); } + public static bool PostKeyboard(IntPtr window, uint key) { + return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); + } + public static bool IsForegroundWindow(IntPtr window) { + return window != IntPtr.Zero && GetForegroundWindow() == window; + } public static bool PostHover(IntPtr window, int x, int y) { return PostMessage(window, 0x0200, UIntPtr.Zero, (IntPtr)((y << 16) | (x & 0xffff))); }