diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 65f62e9b..905e3990 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -129,6 +129,7 @@ $dryRunProof=$null try { Assert-CapWorkerDeadline 'action entry' $boundWindows=@(Assert-CapActionWindowContext) + $wireTargetPreflight=if ($plan.group -cne 'TerminalActions') {Get-CapWorkerWireBinding} else {$null} $windowPreflight=@(foreach ($binding in $boundWindows) { foreach ($scene in $binding.sceneNames) { $identity=Get-CapWorkerWindowIdentity -Phase $binding.actionPhase -SceneName $scene @@ -141,6 +142,9 @@ try { } }) if ($DryRun) { + $wireAlgorithmPreflight=if ($null -ne $wireTargetPreflight) { + Get-CapWireAlgorithmPreflight -Binding $wireTargetPreflight -ExpectedProcessId $selfIdentity.Pid + } else {$null} $helperAvailability=@(foreach ($name in @( 'Install-CapQualifiedActionTopology','Start-CapMatchedActionSeeds','Close-CapMatchedActionSeeds' )) { @@ -181,6 +185,8 @@ try { providerLaunches=0L windowIdentityValidation='SOURCE_PHASE_BINDINGS_VALIDATED_BEFORE_LAUNCH_RUNTIME_WINDOW_NOT_EXECUTED' windowBindings=$windowPreflight + wireTargetBinding=$wireTargetPreflight + wireAlgorithmPreflight=$wireAlgorithmPreflight helperAvailability=$helperAvailability topologyPreparation=$topologyPreparation toolingSourceHead=$head diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 42eb7672..4a17e777 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -149,6 +149,13 @@ function Assert-CapActionWindowContext { } finally {$held.Pin.Dispose()} } +function Get-CapWorkerWireBinding { + $null=Assert-CapActionWindowContext + $binding=if ($plan.PSObject.Properties['wireTargetBinding']) {$plan.wireTargetBinding} else {$null} + return Assert-CapWireTargetBinding -Binding $binding ` + -Repository ([IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))) -OwnedRoot $root +} + function Get-CapWorkerWindowIdentity { param([Parameter(Mandatory)][string]$Phase,[string]$SceneName='') if ($actionSupplementMode) { @@ -1384,6 +1391,7 @@ function Start-CapGraphCodeApp { if ($actionSupplementMode) { $null=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $null=Get-CapMatchedWindowGeometry -Phase $Phase -SceneName $SceneName + if ($Phase -ceq 'wire-completion') {$null=Get-CapWorkerWireBinding} } $binaryKey = if ($Phase -eq 'before') { 'graphcode-before.exe' } else { 'graphcode-after.exe' } $binaryPath = if ($rehearsal) { Assert-CapOwnedPath $job.rehearsalTarget.path } @@ -2453,18 +2461,63 @@ function Invoke-CapMatchedTerminalSceneSupplement { return [pscustomobject]$receipt } +function Read-CapWireTargetObservation { + param([Parameter(Mandatory)]$AppRun,[Parameter(Mandatory)]$Binding) + $null=Assert-CapRootProcess -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity + $graph=Find-CapAutomationElement -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity ` + -ExpectedProcessId $AppRun.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $status=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' + $rows=@(@(Get-CapDirectChildren -Parent $graph)+@($status)|ForEach-Object { + $current=$_.Current + $b=$current.BoundingRectangle + [pscustomobject]@{AutomationId=$current.AutomationId;Name=$current.Name;ProcessId=$current.ProcessId + IsEnabled=$current.IsEnabled;IsOffscreen=$current.IsOffscreen + RuntimeId=@($_.GetRuntimeId());Bounds=@($b.Left,$b.Top,$b.Right,$b.Bottom)} + }) + $metrics=Get-CapWindowMetrics -Window $AppRun.Window -ProcessIdentity $AppRun.Run.Identity + $identity=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName $Binding.scene + $geometry=Get-CapMatchedWindowGeometry -Phase 'wire-completion' -SceneName $Binding.scene + $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $identity -ExpectedOuterBounds $geometry.OuterBounds + $observation=Get-CapWireTargetObservation -Binding $Binding -Rows $rows ` + -ExpectedProcessId $AppRun.Run.Identity.Pid -Dpi $metrics.Dpi + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters @{ + observation=($observation|ConvertTo-Json -Depth 20 -Compress) + rows=($rows|ConvertTo-Json -Depth 10 -Compress)} + return $observation +} + +function Read-CapWireWindowObservation { + param([Parameter(Mandatory)]$AppRun,[Parameter(Mandatory)][string]$Stage) + $hierarchy=@(Get-CapOwnedWindowHierarchy -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity) + $element=[Windows.Automation.AutomationElement]::FromHandle($AppRun.Window) + $uia=@($element.FindAll([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::TrueCondition)|ForEach-Object { + $c=$_.Current;$b=$c.BoundingRectangle + [pscustomobject]@{name=$c.Name;id=$c.AutomationId;processId=$c.ProcessId;class=$c.ClassName + enabled=$c.IsEnabled;offscreen=$c.IsOffscreen;bounds=@($b.Left,$b.Top,$b.Right,$b.Bottom)} + }) + Write-CapTrace -Command 'WireRootWindowObservation' -Parameters @{ + stage=$Stage;hierarchy=($hierarchy|ConvertTo-Json -Depth 10 -Compress) + uia=($uia|ConvertTo-Json -Depth 10 -Compress)} + $null=Assert-CapUiaProcessIds -ExpectedProcessId $AppRun.Run.Identity.Pid ` + -ObservedProcessIds @($uia|ForEach-Object {[int]$_.processId}) + return $hierarchy +} + function Invoke-CapWireCompletionSupplement { param([Parameter(Mandatory)][string]$StubDaemonPath) $phase='wire-completion' $null=Get-CapWorkerWindowIdentity -Phase $phase -SceneName 's-goal-unwired-unselected' + $binding=Get-CapWorkerWireBinding $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory $phase) - $projectRoot=Join-Path $root 'fixtures\wire-project' + $projectRoot=$binding.projectPath $null=New-Item -ItemType Directory -Path $projectRoot -Force - $sourceId='60000000-0000-4000-8000-000000000001' - $targetId='60000000-0000-4000-8000-000000000002' + $sourceId=$binding.sourceNodeId + $targetId=$binding.targetNodeId $pipe="gc-wire-$([guid]::NewGuid().ToString('N'))" $resultPath=Join-Path $roots.Peer 'stub-result.json' - $stub=$null;$app=$null;$stubCleanup=$null + $stub=$null;$app=$null;$stubCleanup=$null;$primary=$null try { $stub=Start-CapManagedProcess -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( '-NoProfile','-File',$StubDaemonPath, @@ -2482,52 +2535,76 @@ function Invoke-CapWireCompletionSupplement { } $null=Invoke-CapSceneAction -AppRun $app -Action 'open-project' -Name 'Stub project' $null=Invoke-CapSceneAction -AppRun $app -Action 'actual-size' - $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` - -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' - $children=@(Get-CapDirectChildren -Parent $graph) - $sourceCardId=Get-CapMatchedAutomationId "project-card:$projectRoot`:$sourceId" - $targetCardId=Get-CapMatchedAutomationId "project-card:$projectRoot`:$targetId" - $sourceCard=@($children | Where-Object { - $_.Current.AutomationId -ceq $sourceCardId -and $_.Current.Name -ceq 'Wire source'}) - $targetCard=@($children | Where-Object { - $_.Current.AutomationId -ceq $targetCardId -and $_.Current.Name -ceq 'Wire target'}) - if ($sourceCard.Count -ne 1 -or $targetCard.Count -ne 1) { - throw 'Wire supplement did not expose its exact source and target cards.' - } - $wireIdentity="wire-node:$projectRoot`:$sourceId" - $wireId=Get-CapMatchedAutomationId $wireIdentity - $wire=@($children | Where-Object { - $_.Current.AutomationId -ceq $wireId -and $_.Current.Name -ceq 'Wire it up'}) - if ($wire.Count -ne 1) {throw 'Wire supplement source omitted its exact Wire it up control.'} - $null=Invoke-CapSceneAction -AppRun $app -Action 'wire-node' -SubjectId $wireIdentity + $sourceCardId=$binding.sourceCardId + $targetCardId=$binding.targetCardId + $wireId=$binding.wireId + $heldRootElement=[Windows.Automation.AutomationElement]::FromHandle($app.Window) + $rootRuntime=@($heldRootElement.GetRuntimeId()) + $beforeHierarchy=@(Read-CapWireWindowObservation -AppRun $app -Stage 'before-arm') + if ($null -ne (Resolve-CapOwnedSheet -Rows $beforeHierarchy -RootHandle $app.Window ` + -ExpectedProcessId $app.Run.Identity.Pid)) {throw 'Wire action started with an existing owned modal sheet.'} + $null=Invoke-CapSceneAction -AppRun $app -Action 'wire-node' -SubjectId $binding.wireIdentity $wireStatus=Wait-CapMatchedWireMode -AppRun $app ` -DeadlineUtc (Get-CapActionDeadline -WorkerDeadlineUtc $leaseDeadlineUtc) - $targetBounds=$targetCard[0].Current.BoundingRectangle - $targetX=[int](($targetBounds.Left+$targetBounds.Right)/2) - $targetY=[int](($targetBounds.Top+$targetBounds.Bottom)/2) - if (-not [GraphCodeUiaGateState]::PostPointerClickScreen($app.Window,$targetX,$targetY)) { - throw 'Wire supplement target click was rejected.' - } - $form=[IntPtr]::Zero - $deadline=[DateTime]::UtcNow.AddSeconds(5) + $binding=Get-CapWorkerWireBinding + $targetObservation=Read-CapWireTargetObservation -AppRun $app -Binding $binding + $reacquired=Read-CapWireTargetObservation -AppRun $app -Binding $binding + Assert-CapWireTargetStable -First $targetObservation -Second $reacquired -RootBefore $rootRuntime ` + -RootAfter @([Windows.Automation.AutomationElement]::FromHandle($app.Window).GetRuntimeId()) + $null=Assert-CapUiaProcessIds -ExpectedProcessId $app.Run.Identity.Pid ` + -ObservedProcessIds @($heldRootElement.Current.ProcessId) + $null=Assert-CapRootProcess -RootHandle $app.Window -ProcessIdentity $app.Run.Identity + $targetX=$reacquired.screenPoint[0];$targetY=$reacquired.screenPoint[1] + Write-CapTrace -Command 'WirePointerDispatchIntent' -Parameters @{ + root=[long]$app.Window;process=$app.Run.Identity.Pid;generation=$app.Run.Identity.CreationFileTimeUtc + target=($reacquired|ConvertTo-Json -Depth 20 -Compress) + beforeHierarchy=($beforeHierarchy|ConvertTo-Json -Depth 10 -Compress) + rootRuntimeId=($rootRuntime|ConvertTo-Json -Compress)} + $dispatch=[CapNative]::PostPointerClickScreen($app.Window,$targetX,$targetY) + Write-CapTrace -Command 'WirePointerDispatchResult' -Parameters @{ + receipt=($dispatch|ConvertTo-Json -Depth 10 -Compress)} + $sheet=$null + $deadline=Get-CapActionDeadline -WorkerDeadlineUtc $leaseDeadlineUtc -MaximumWaitSeconds 5 do { - $form=[GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$app.Run.Identity.Pid) - if ($form -ne [IntPtr]::Zero) {break} + $hierarchy=@(Read-CapWireWindowObservation -AppRun $app -Stage 'after-posted-target') + $sheet=Resolve-CapOwnedSheet -Rows $hierarchy -RootHandle $app.Window ` + -ExpectedProcessId $app.Run.Identity.Pid + if ($null -ne $sheet) {break} Assert-CapWorkerDeadline 'Wire completion native form';Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) - if ($form -eq [IntPtr]::Zero) {throw 'Wire supplement target did not open the native edge form.'} - $formElement=[Windows.Automation.AutomationElement]::FromHandle($form) + if ($null -eq $sheet) {throw 'Wire supplement target did not expose an owned sheet and native edge child; see hierarchy census.'} + $formElement=[Windows.Automation.AutomationElement]::FromHandle([IntPtr]$sheet.host.Handle) + $modalRuntime=@($formElement.GetRuntimeId()) + $modalControls=@($formElement.FindAll([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::TrueCondition)|ForEach-Object { + $c=$_.Current + [pscustomobject]@{name=$c.Name;id=$c.AutomationId;processId=$c.ProcessId + class=$c.ClassName;enabled=$c.IsEnabled;offscreen=$c.IsOffscreen} + }) + Write-CapTrace -Command 'WireOwnedModalControls' -Parameters @{ + sheet=($sheet|ConvertTo-Json -Depth 10 -Compress) + controls=($modalControls|ConvertTo-Json -Depth 10 -Compress)} + $null=Assert-CapUiaProcessIds -ExpectedProcessId $app.Run.Identity.Pid ` + -ObservedProcessIds @($modalControls|ForEach-Object {[int]$_.processId}) $buttonCondition=[Windows.Automation.AndCondition]::new( [Windows.Automation.PropertyCondition]::new( [Windows.Automation.AutomationElement]::ControlTypeProperty, [Windows.Automation.ControlType]::Button), [Windows.Automation.PropertyCondition]::new( [Windows.Automation.AutomationElement]::NameProperty,'Create')) - $create=$formElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$buttonCondition) - if ($null -eq $create -or -not $create.Current.IsEnabled) { + $creates=@($formElement.FindAll([Windows.Automation.TreeScope]::Descendants,$buttonCondition)) + if ($creates.Count -ne 1 -or -not $creates[0].Current.IsEnabled -or $creates[0].Current.IsOffscreen) { throw 'Wire supplement native edge form omitted its enabled Create action.' } - $create.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() + $latest=Resolve-CapOwnedSheet -Rows @( + Get-CapOwnedWindowHierarchy -RootHandle $app.Window -ProcessIdentity $app.Run.Identity) ` + -RootHandle $app.Window -ExpectedProcessId $app.Run.Identity.Pid + if ($null -eq $latest -or $latest.host.Handle -ne $sheet.host.Handle -or + $latest.content.Handle -ne $sheet.content.Handle -or ($modalRuntime -join ',') -cne + (@([Windows.Automation.AutomationElement]::FromHandle([IntPtr]$latest.host.Handle).GetRuntimeId()) -join ',')) { + throw 'Owned wire sheet was replaced before Create.' + } + $creates[0].GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() $stubResult=$null $deadline=[DateTime]::UtcNow.AddSeconds(5) do { @@ -2572,8 +2649,13 @@ function Invoke-CapWireCompletionSupplement { sourceCardAutomationId=$sourceCardId targetCardAutomationId=$targetCardId wireAutomationId=$wireId - armedStatus=$wireStatus.Current.Name + armedStatus=$reacquired.mode targetScreenPoint=@($targetX,$targetY) + targetObservation=$reacquired + pointerDispatch=$dispatch + windowHierarchyBefore=$beforeHierarchy + windowHierarchyAfter=$hierarchy + modalControls=$modalControls edgeId=$edge.id edgeSpec=[pscustomobject]@{ kind=$edge.kind;condition=$edge.condition @@ -2592,16 +2674,29 @@ function Invoke-CapWireCompletionSupplement { correlated=$stubResult.unansweredRequests.Count -eq 0 } return $receipt + } catch { + $primary=$_ + Write-CapTrace -Command 'WireSupplementPrimaryFailure' -Parameters @{error=$_.Exception.ToString()} + throw } finally { - if ($app) {Close-CapGraphCodeApp -AppRun $app} - if ($stub) { - $stubCleanup=Stop-CapManagedProcess -Identity $stub.Identity -TimeoutMilliseconds 5000 - if (-not $stub.StdoutTask.Wait(2000) -or -not $stub.StderrTask.Wait(2000)) { - throw 'Wire supplement stub streams did not reach EOF.' + $wireCleanupErrors=[Collections.Generic.List[object]]::new() + try {if ($app) {Close-CapGraphCodeApp -AppRun $app}} + catch {$wireCleanupErrors.Add($_)} + try { + if ($stub) { + $stubCleanup=Stop-CapManagedProcess -Identity $stub.Identity -TimeoutMilliseconds 5000 + if (-not $stub.StdoutTask.Wait(2000) -or -not $stub.StderrTask.Wait(2000)) { + throw 'Wire supplement stub streams did not reach EOF.' + } + $stub.Identity.Process.Dispose() } - $stub.Identity.Process.Dispose() + if ($stubCleanup) {$cleanupReceipts.Add($stubCleanup)} + } catch {$wireCleanupErrors.Add($_)} + foreach ($cleanupError in $wireCleanupErrors) { + $cleanupFailures.Add("Wire supplement cleanup: $($cleanupError.Exception.Message)") + Write-CapTrace -Command 'WireSupplementCleanupFailure' -Parameters @{error=$cleanupError.Exception.ToString()} } - if ($stubCleanup) {$cleanupReceipts.Add($stubCleanup)} + if (-not $primary -and $wireCleanupErrors.Count -gt 0) {throw $wireCleanupErrors[0]} } } diff --git a/Tools/windows/Prepare-MatchedActionSupplement.ps1 b/Tools/windows/Prepare-MatchedActionSupplement.ps1 index 7c655c89..e0748a7c 100644 --- a/Tools/windows/Prepare-MatchedActionSupplement.ps1 +++ b/Tools/windows/Prepare-MatchedActionSupplement.ps1 @@ -196,6 +196,9 @@ $plan=[ordered]@{ existingSceneManifestSha256=$ExistingSceneManifestSha256.ToUpperInvariant() expectedScenes=$expectedScenes actionWindowBindings=$actionWindowBindings + wireTargetBinding=$(if ($Group -cne 'TerminalActions') { + Get-CapWireTargetBinding -Repository $repositoryRoot -OwnedRoot $root + } else {$null}) imageBindings=$bindings expectedReceiptCount=[long]$expectedScenes.Count qualifiedAuthority=$(if ($Group -ceq 'FinalBoundedActions') { diff --git a/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 index ef21c95e..26253313 100644 --- a/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 +++ b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 @@ -44,6 +44,8 @@ function Initialize-BindingCase { group=$Group;sourceHead=$sourceHead;sourceTree=('b'*40) expectedScenes=@($bindings|ForEach-Object {$_.sceneNames}) actionWindowBindings=$bindings;jobPath=(Join-Path $caseRoot 'job.json');jobSha256='' + wireTargetBinding=(Get-CapWireTargetBinding ` + -Repository ([IO.Path]::GetFullPath((Join-Path $driverDirectory '..\..'))) -OwnedRoot $caseRoot) } $script:planPath=Join-Path $caseRoot 'plan.json' $script:authorizationPath=Join-Path $caseRoot 'authorization.json' @@ -178,6 +180,23 @@ try {$null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity ( -ExpectedOuterBounds $geometry.OuterBounds} catch {$failure=$_} Assert-BindingCase ($null -ne $failure -and $failure.Exception.Message.Contains('identity differs')) 'Placed guard accepted drift.' $results.Add(@{name='placed guard rejects drift after identity reacquire in metrics model';passed=$true}) +foreach ($fault in @('missing','null','targetNodeId','sourceNodeId','sourceHashes')) { + Initialize-BindingCase + switch ($fault) { + 'missing' {$plan.PSObject.Properties.Remove('wireTargetBinding')} + 'null' {$plan.wireTargetBinding=$null} + default {$plan.wireTargetBinding.$fault='foreign'} + } + Save-BindingCase + $failure=$null + try {Start-CapGraphCodeApp -Roots @{} -Phase 'wire-completion' ` + -SceneName 's-goal-unwired-unselected' -PipeName 'NO_LAUNCH' -SessionPrefix 'NO_LAUNCH'|Out-Null} + catch {$failure=$_} + Assert-BindingCase ($null -ne $failure -and $failure.Exception.Message.Contains('planned source/target binding')) ` + "Startup did not reject $fault wire target before launch: $failure" + $results.Add(@{name="startup rejects $fault wire target before launch";passed=$true}) +} +Initialize-BindingCase $reachedLaunch=$false & { function Start-CapJobLauncher { @@ -199,4 +218,4 @@ $report=[ordered]@{suite='ActionWindowIdentity';executed=$results.Count;passed=$ productLaunches=0;providerLaunches=0;runtimeWindowEvidence=$false;cases=@($results)} $null=Write-BindingJson (Join-Path $caseRoot 'report.json') $report [pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress -if ($results.Count -ne 57) {throw "Unexpected binding contract count: $($results.Count)"} +if ($results.Count -ne 62) {throw "Unexpected binding contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 index f3df7a8d..10c7d9e8 100644 --- a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 +++ b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 @@ -105,6 +105,12 @@ foreach ($name in @('Invoke-MatchedActionSupplement.ps1','Invoke-UiCaptureWorker 'Stub-Daemon.ps1','Start-MatchedActionSessions.ps1')) { Copy-Item -LiteralPath (Join-Path $driverDirectory $name) -Destination (Join-Path $fixtureTools $name) } +$fixtureSource=Join-Path $fixtureRepository 'graphcode-windows\src' +$null=New-Item -ItemType Directory -Path $fixtureSource -Force +foreach ($name in @('GraphCanvas.zig','DesignTokens.zig','NativeForms.zig','SheetFramework.zig')) { + Copy-Item -LiteralPath (Join-Path $driverDirectory "..\..\graphcode-windows\src\$name") ` + -Destination (Join-Path $fixtureSource $name) +} & git init --quiet $fixtureRepository & git -C $fixtureRepository config core.autocrlf false & git -C $fixtureRepository add . @@ -140,6 +146,7 @@ foreach ($scenario in @('accepted','setup-refusal')) { sourceHead=$fixtureHead;sourceTree=$fixtureTree;group='FinalBoundedActions' expectedScenes=@($windowBindings|ForEach-Object {$_.sceneNames}) actionWindowBindings=$windowBindings + wireTargetBinding=(Get-CapWireTargetBinding -Repository $fixtureRepository -OwnedRoot $owned) moduleSha256=(Get-FileHash (Join-Path $fixtureTools 'UiCaptureDriver.psm1')).Hash workerSha256=(Get-FileHash (Join-Path $fixtureTools 'Invoke-UiCaptureWorker.ps1')).Hash stubDaemonSha256=(Get-FileHash (Join-Path $fixtureTools 'Stub-Daemon.ps1')).Hash @@ -231,6 +238,12 @@ foreach ($scenario in @('accepted','setup-refusal')) { Assert-ImportCase ($manifest.dryRun.helperAvailability.Count -eq 3 -and @($manifest.dryRun.helperAvailability | Where-Object available -NE $true).Count -eq 0 -and $manifest.dryRun.topologyPreparation.Count -eq 5) 'Production dry run omitted imported helpers or qualified topology preparation.' + Assert-ImportCase ($manifest.dryRun.wireTargetBinding.targetNodeId -ceq + $actionPlan.wireTargetBinding.targetNodeId -and + $manifest.dryRun.wireAlgorithmPreflight.kind -ceq + 'MODEL_FIXTURE_SHARED_PRODUCTION_ALGORITHMS_NOT_NATIVE_ACTION' -and + $manifest.dryRun.wireAlgorithmPreflight.sheet.content.Handle -eq 4) ` + 'Production dry run omitted exact target binding or shared model algorithm preflight.' foreach ($prepared in $manifest.dryRun.topologyPreparation) { $source=@($topologies | Where-Object scene -CEQ $prepared.scene) $seedManifest=Get-Content $prepared.seedPlan.sessionManifestPath -Raw | ConvertFrom-Json -DateKind String diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 2a276cd2..b47fceaa 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -772,6 +772,20 @@ function Assert-CapRejects { 'CapNative|MonitorFromPoint|user32.dll|MonitorFromPoint', 'CapNative|GetMonitorInfo|user32.dll|GetMonitorInfoW', 'CapNative|SetThreadDpiAwarenessContext|user32.dll|SetThreadDpiAwarenessContext', + 'CapNative|GetThreadDpiAwarenessContext|user32.dll|GetThreadDpiAwarenessContext', + 'CapNative|EnumWindows|user32.dll|EnumWindows', + 'CapNative|EnumChildWindows|user32.dll|EnumChildWindows', + 'CapNative|GetAncestor|user32.dll|GetAncestor', + 'CapNative|GetWindow|user32.dll|GetWindow', + 'CapNative|IsWindowVisible|user32.dll|IsWindowVisible', + 'CapNative|IsWindowEnabled|user32.dll|IsWindowEnabled', + 'CapNative|GetClassName|user32.dll|GetClassNameW', + 'CapNative|SendMessageTimeoutW|user32.dll|SendMessageTimeoutW', + 'CapNative|ScreenToClient|user32.dll|ScreenToClient', + 'CapNative|WindowFromPoint|user32.dll|WindowFromPoint', + 'CapNative|GetForegroundWindow|user32.dll|GetForegroundWindow', + 'CapNative|GetCursorPos|user32.dll|GetCursorPos', + 'CapNative|PostMessage|user32.dll|PostMessageA', 'CapNative|PrintWindow|user32.dll|PrintWindow', 'CapProcessJob|CreateJobObjectW|kernel32.dll|CreateJobObjectW', 'CapProcessJob|SetInformationJobObject|kernel32.dll|SetInformationJobObject', @@ -785,7 +799,7 @@ function Assert-CapRejects { 'CapProcessJob|SetHandleInformation|kernel32.dll|SetHandleInformation' ) $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) - Assert-CapTest ($actual.Count -eq 42 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static42 export contract differs.' + Assert-CapTest ($actual.Count -eq 56 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static56 export contract differs.' return @{ subcases = 39; 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' { diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index ab43cb10..feb299ae 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -6070,9 +6070,9 @@ $actionBindingSummaries=@($actionBindingOutput | Where-Object { }) if ($actionBindingSummaries.Count -ne 1) {throw "RED: action binding structured count missing or duplicated"} $actionBindingSummary=ConvertFrom-Json $actionBindingSummaries[0] -if ($actionBindingSummary.executed -ne 57 -or $actionBindingSummary.passed -ne 57 -or +if ($actionBindingSummary.executed -ne 62 -or $actionBindingSummary.passed -ne 62 -or $actionBindingSummary.failed -ne 0 -or $actionBindingSummary.skipped -ne 0) { - throw "RED: action window binding requires all 57 contracts without skips" + throw "RED: action window binding requires all 62 contracts without skips" } $actionTopologyOutput=@(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionTopologyImport.Tests.ps1")) $actionTopologyOutput | ForEach-Object {Write-Host $_} @@ -6086,6 +6086,18 @@ if ($actionTopologySummary.executed -ne 16 -or $actionTopologySummary.passed -ne $actionTopologySummary.failed -ne 0 -or $actionTopologySummary.skipped -ne 0) { throw "RED: action topology import requires all 16 contracts without skips" } +$wireTargetingOutput=@(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'WireTargeting.Tests.ps1')) +$wireTargetingOutput|ForEach-Object {Write-Host $_} +if ($LASTEXITCODE -ne 0) {throw 'RED: wire targeting contract child failed'} +$wireTargetingSummaries=@($wireTargetingOutput|Where-Object { + $_ -is [string] -and $_.StartsWith('{"suite":"WireTargeting",') +}) +if ($wireTargetingSummaries.Count -ne 1) {throw 'RED: wire targeting count missing or duplicated'} +$wireTargetingSummary=ConvertFrom-Json $wireTargetingSummaries[0] +if ($wireTargetingSummary.executed -ne 56 -or $wireTargetingSummary.passed -ne 56 -or + $wireTargetingSummary.failed -ne 0 -or $wireTargetingSummary.skipped -ne 0) { + throw 'RED: wire targeting requires all 56 contracts without skips' +} $agentCommandOutput = @(& $pwsh -NoProfile -File ( Join-Path $PSScriptRoot "Invoke-AgentCommand.Tests.ps1")) $agentCommandSummary = "Invoke-AgentCommand.Tests.ps1: PASS; executed=6; cases=argument-stream-marker,zero-arguments,existing-console,existing-marker,parse-failure,child-nonzero" diff --git a/Tools/windows/Tests/WireTargeting.Tests.ps1 b/Tools/windows/Tests/WireTargeting.Tests.ps1 new file mode 100644 index 00000000..5b49abe1 --- /dev/null +++ b/Tools/windows/Tests/WireTargeting.Tests.ps1 @@ -0,0 +1,304 @@ +param( + [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('wt' + [guid]::NewGuid().ToString('N').Substring(0,8))), + [switch]$LegacyBehavior +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$directory=Split-Path -Parent $PSScriptRoot +$repository=Split-Path -Parent (Split-Path -Parent $directory) +Import-Module (Join-Path $directory 'UiCaptureDriver.psm1') -Force -WarningAction SilentlyContinue +if (Test-Path -LiteralPath $ScratchRoot) {throw 'Refusing to overwrite wire evidence.'} +$null=New-Item -ItemType Directory -Path $ScratchRoot +Set-CapTracePath -Path (Join-Path $ScratchRoot 'calls.jsonl') +$results=[Collections.Generic.List[object]]::new() +function Test-WireCase { + param([string]$Name,[scriptblock]$CaseAction) + try {& $CaseAction; $results.Add(@{name=$Name;passed=$true})} + catch {$results.Add(@{name=$Name;passed=$false;error=$_.Exception.Message})} +} +function Assert-WireCase { + param([bool]$Condition,[string]$Message) + if (-not $Condition) {throw $Message} +} +function Reject-WireCase { + param([string]$Name,[scriptblock]$Body,[string]$Message) + Test-WireCase $Name { + $failure=$null + try {& $Body|Out-Null} catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and $failure.Exception.Message.Contains($Message)) ` + "Expected refusal '$Message'; actual=$failure" + } +} + +# Harmless native hierarchy with the same parent/owner distinction as the sheet. +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class WireFixture { + delegate IntPtr Proc(IntPtr window,uint message,IntPtr wp,IntPtr lp); + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] + struct Class { + public uint Style; public Proc Procedure; public int ClassExtra,WindowExtra; + public IntPtr Instance,Icon,Cursor,Brush; public string Menu,Name; + } + [DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern ushort RegisterClassW(ref Class cls); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern IntPtr DefWindowProcW(IntPtr w,uint m,IntPtr wp,IntPtr lp); + static Proc procedure=DefWindowProcW; + static WireFixture() { + var cls=new Class { Procedure=procedure, Name="WireTargetFixture" }; + if(RegisterClassW(ref cls)==0) throw new Exception("Fixture class registration failed."); + cls.Name="WireTargetFixtureContent"; + if(RegisterClassW(ref cls)==0) throw new Exception("Fixture child class registration failed."); + } + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern IntPtr CreateWindowExW(uint ex, string cls, string name, uint style, + int x, int y, int w, int h, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr value); + [DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr window); + [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); + [DllImport("user32.dll")] public static extern IntPtr GetThreadDpiAwarenessContext(); + public static IntPtr Create(IntPtr parent, bool child, string name) { + IntPtr result=CreateWindowExW(0x08000000,child ? "WireTargetFixtureContent" : "WireTargetFixture", + name,child ? 0x50000000u : 0x90000000u, + 20,20,250,96,parent,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(result==IntPtr.Zero) throw new Exception("Fixture CreateWindow failed."); + return result; + } +} +'@ +$root=[IntPtr]::Zero;$hostWindow=[IntPtr]::Zero;$content=[IntPtr]::Zero +try { + $root=[WireFixture]::Create([IntPtr]::Zero,$false,'wire fixture owner') + $hostWindow=[WireFixture]::Create($root,$false,'wire fixture sheet') + $content=[WireFixture]::Create($hostWindow,$true,'wire fixture content') + if ($LegacyBehavior) { + Test-WireCase 'owned child form is observable, not just a top-level class' { + $actual=[GraphCodeUiaGateState]::FindTopLevel('WireTargetFixtureContent',[uint32]$PID) + Assert-WireCase ($actual -eq $content) 'Top-level-only oracle missed the owned child content.' + } + Test-WireCase 'target point excludes the inline Mark action' { + $point=@(125,48) + Assert-WireCase (-not ($point[0] -ge 90 -and $point[0] -lt 176 -and + $point[1] -ge 31 -and $point[1] -lt 51)) 'Card midpoint hits Mark as entry before generic node selection.' + } + } else { + $binding=Get-CapWireTargetBinding -Repository $repository -OwnedRoot $ScratchRoot + Test-WireCase 'production binding preserves exact planned UUIDs and source-derived title band' { + $null=Assert-CapWireTargetBinding $binding $repository $ScratchRoot + Assert-WireCase (($binding.safeLogicalBounds -join ',') -ceq '11,0,176,31' -and + $binding.sourceNodeId -cne $binding.targetNodeId) 'Invalid source-derived safe region.' + } + foreach ($field in @('sourceNodeId','targetNodeId','sourceCardId','targetCardId','scene','phase','projectPath','wireId','sourceHashes')) { + $changed=$binding|ConvertTo-Json -Depth 20|ConvertFrom-Json + $changed.$field='foreign' + Reject-WireCase "reject planned $field drift" { + Assert-CapWireTargetBinding $changed $repository $ScratchRoot + } 'planned source/target binding differs' + } + Reject-WireCase 'reject absent target plan before launch' { + Assert-CapWireTargetBinding $null $repository $ScratchRoot + } 'binding differs' + function New-WireRow { + param([string]$Id,[string]$Name,[double[]]$Bounds) + [pscustomobject]@{AutomationId=$Id;Name=$Name;ProcessId=$PID;IsEnabled=$true + IsOffscreen=$false;Bounds=$Bounds;RuntimeId=@(1,2)} + } + $baseRows=@( + (New-WireRow $binding.sourceCardId 'Wire source' @(0,0,250,96)), + (New-WireRow $binding.targetCardId 'Wire target' @(300,0,550,96)), + (New-WireRow $binding.wireId 'Wire it up' @(11,31,84,51)), + (New-WireRow 'target-mark' 'Mark as entry' @(390,31,476,51)), + (New-WireRow 'status' 'Choose a target loop to wire this entry' @(0,150,500,180))) + foreach ($dpi in @(96,192)) { + $rows=$baseRows|ConvertTo-Json -Depth 20|ConvertFrom-Json + foreach ($row in $rows) {$row.Bounds=@($row.Bounds|ForEach-Object {$_*$dpi/96})} + Test-WireCase "production safe target excludes inline Mark at DPI$dpi" { + $observation=Get-CapWireTargetObservation $binding $rows $PID $dpi + Assert-WireCase ($observation.screenPoint[1] -lt 31*$dpi/96 -and + $observation.screenPoint[0] -gt 300*$dpi/96) 'Target point hits inline action.' + } + } + Test-WireCase 'production algorithm preflight is explicitly model-only with no launch' { + $proof=Get-CapWireAlgorithmPreflight $binding $PID + Assert-WireCase (-not $proof.runtimeWindowEvidence -and $proof.productLaunches -eq 0 -and + $proof.providerLaunches -eq 0 -and $proof.sheet.content.Handle -eq 4) 'Preflight inflated evidence.' + } + $first=Get-CapWireTargetObservation $binding $baseRows $PID 96 + Test-WireCase 'immutable reacquisition preserves exact target and root identity' { + Assert-CapWireTargetStable $first $first @(1,2) @(1,2) + } + foreach ($fault in @('source','target','runtime','bounds','mode','root')) { + $second=$first|ConvertTo-Json -Depth 20|ConvertFrom-Json + $rootAfter=@(1,2) + switch ($fault) { + 'source' {$second.source.AutomationId='foreign'} + 'target' {$second.target.AutomationId='foreign'} + 'runtime' {$second.target.RuntimeId=@(1,3)} + 'bounds' {$second.target.Bounds[0]++} + 'mode' {$second.mode='not armed'} + 'root' {$rootAfter=@(1,3)} + } + Reject-WireCase "reacquisition rejects $fault drift" { + Assert-CapWireTargetStable $first $second @(1,2) $rootAfter + } 'drifted before dispatch' + } + foreach ($fault in @('source','target','mode','foreign-process','offscreen','disabled','bounds','NaN','duplicate','overlap')) { + $rows=@($baseRows|ConvertTo-Json -Depth 20|ConvertFrom-Json) + switch ($fault) { + 'source' {$rows[0].AutomationId='foreign'} + 'target' {$rows[1].Name='Wrong target'} + 'mode' {$rows[4].Name='Marked as an entry for this session'} + 'foreign-process' {$rows[1].ProcessId++} + 'offscreen' {$rows[1].IsOffscreen=$true} + 'disabled' {$rows[1].IsEnabled=$false} + 'bounds' {$rows[1].Bounds[2]++ ; $rows[1].Bounds[2]++} + 'NaN' {$rows[1].Bounds[0]=[double]::NaN} + 'duplicate' {$rows+= $rows[1]} + 'overlap' {$rows[3].Bounds=@(300,0,550,31)} + } + $message=switch ($fault) { + 'foreign-process' {'does not match owned PID'} + {$_ -in @('offscreen','disabled','bounds','NaN')} {'bounds/scale'} + 'overlap' {'Unsafe wire target point'} + default {'identity or active Wire mode'} + } + Reject-WireCase "reject observed $fault" { + Get-CapWireTargetObservation $binding $rows $PID 96 + } $message + } + $self=[Diagnostics.Process]::GetCurrentProcess() + $identity=Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName -Role 'wire-fixture' + $nativeRows=@(Get-CapOwnedWindowHierarchy -RootHandle $root -ProcessIdentity $identity) + $changedIdentity=$identity.PSObject.Copy() + $changedIdentity.CreationFileTimeUtc++ + Reject-WireCase 'owned census rejects changed held process generation' { + Get-CapOwnedWindowHierarchy -RootHandle $root -ProcessIdentity $changedIdentity + } 'Kernel process identity changed' + Reject-WireCase 'owned census rejects replaced or absent root HWND' { + Get-CapOwnedWindowHierarchy -RootHandle ([IntPtr]::Zero) -ProcessIdentity $identity + } 'GetWindowThreadProcessId failed' + Test-WireCase 'owned census trace preserves numeric handles and bounds, not object type names' { + $trace=@(Get-Content (Join-Path $ScratchRoot 'calls.jsonl')|ForEach-Object {$_|ConvertFrom-Json}| + Where-Object command -CEQ 'Get-CapOwnedWindowHierarchy')[-1] + $observed=@($trace.parameters.rows|ConvertFrom-Json) + Assert-WireCase (@($observed|Where-Object Handle -EQ $content.ToInt64()).Count -eq 1) ` + 'Census trace lost actual native rows.' + } + [IO.File]::WriteAllText((Join-Path $ScratchRoot 'native-census.json'), + (@{root=[long]$root;host=[long]$hostWindow;content=[long]$content;rows=$nativeRows}|ConvertTo-Json -Depth 20)) + Test-WireCase 'native census observes child and binds it to the owned sheet host' { + $found=Resolve-CapOwnedSheet -Rows @($nativeRows|Where-Object Handle -NE $root.ToInt64()) ` + -RootHandle $root -ExpectedProcessId $PID -SheetClass 'WireTargetFixture' -ContentClass 'WireTargetFixtureContent' + Assert-WireCase ($found.host.Handle -eq $hostWindow.ToInt64() -and + $found.content.Handle -eq $content.ToInt64()) 'Owned child was not observed.' + } + $model=@( + [pscustomobject]@{Handle=10L;Parent=0L;Owner=1L;ProcessId=$PID;ClassName='GraphCodeSheetHost';Visible=$true}, + [pscustomobject]@{Handle=11L;Parent=10L;Owner=0L;ProcessId=$PID;ClassName='viewport';Visible=$true}, + [pscustomobject]@{Handle=12L;Parent=11L;Owner=0L;ProcessId=$PID;ClassName='GraphCodeNativeForm';Visible=$true}) + Test-WireCase 'production oracle follows viewport ancestor rather than top-level-only class' { + $found=Resolve-CapOwnedSheet $model 1 $PID + Assert-WireCase ($found.content.Handle -eq 12 -and $found.host.Handle -eq 10) 'Child hierarchy missed.' + } + foreach ($fault in @('foreign-owner','missing-host','hidden-host','missing-child')) { + $rows=@($model|ConvertTo-Json|ConvertFrom-Json) + switch ($fault) { + 'foreign-owner' {$rows[0].Owner=99} + 'missing-host' {$rows=@($rows|Where-Object Handle -NE 10)} + 'hidden-host' {$rows[0].Visible=$false} + 'missing-child' {$rows=@($rows|Where-Object Handle -NE 12)} + } + Test-WireCase "no success for $fault" { + Assert-WireCase ($null -eq (Resolve-CapOwnedSheet $rows 1 $PID)) 'Foreign/missing sheet accepted.' + } + } + foreach ($fault in @('foreign-process','duplicate-host','duplicate-child','missing-ancestor','cycle')) { + $rows=@($model|ConvertTo-Json|ConvertFrom-Json) + switch ($fault) { + 'foreign-process' {$rows[2].ProcessId++} + 'duplicate-host' {$rows+=$rows[0]} + 'duplicate-child' {$rows+=$rows[2]} + 'missing-ancestor' {$rows[2].Parent=99} + 'cycle' {$rows[1].Parent=11} + } + $message=switch ($fault) { + 'foreign-process' {'does not match owned PID'} + 'duplicate-host' {'Ambiguous owned sheet'} + 'missing-ancestor' {'Missing or ambiguous sheet ancestor'} + 'cycle' {'Cyclic sheet'} + default {'Missing or ambiguous owned sheet child'} + } + Reject-WireCase "reject sheet $fault" {Resolve-CapOwnedSheet $rows 1 $PID} $message + } + Test-WireCase 'native PMv2 conversion dispatch persists coordinates and restores caller awareness' { + $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-1)) + try { + $before=[WireFixture]::GetThreadDpiAwarenessContext() + $receipt=[CapNative]::PostPointerClickScreen($hostWindow,($b[0]+10),($b[1]+10)) + Assert-WireCase ($receipt.ClientX -eq 10 -and $receipt.ClientY -eq 10 -and + $receipt.PackedLParam -eq ((10 -shl 16) -bor 10) -and $receipt.MovePosted -and + $receipt.DownPosted -and $receipt.UpPosted -and + [WireFixture]::GetThreadDpiAwarenessContext() -eq $before) 'DPI conversion/receipt/restore failed.' + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($previous)} + } + Reject-WireCase 'invalid native HWND is not a successful click' { + [CapNative]::PostPointerClickScreen([IntPtr]::Zero,0,0) + } 'recipient differs' + Reject-WireCase 'native conversion error is explicit, never a successful coordinate fallback' { + [CapNative]::ConvertPointerScreen([IntPtr]123,0,0) + } 'ScreenToClient failed' + Reject-WireCase 'native conversion rejects overflowing screen coordinates before native truncation' { + [CapNative]::ConvertPointerScreen($hostWindow,[int]::MaxValue,[int]::MaxValue) + } 'signed native conversion range' + foreach ($failedMessage in @(0x0200,0x0201,0x0202)) { + $script:messages=[Collections.Generic.List[uint32]]::new() + $poster=[Func[uint32,uint32,long,bool]]{ + param($message,$keys,$position) + $script:messages.Add($message) + return $message -ne $failedMessage + } + Reject-WireCase "native dispatch rejects message $failedMessage failure" { + [CapNative]::DispatchPointerMessages([CapPointerReceipt]::new(),$poster) + } 'Native pointer dispatch failed' + Test-WireCase "native dispatch stops at failed message $failedMessage" { + Assert-WireCase ($script:messages[-1] -eq $failedMessage -and + $script:messages.Count -eq ($failedMessage-0x0200+1)) 'Dispatch continued after rejection.' + } + } + Test-WireCase 'worker preserves original primary when stub cleanup also refuses' { + & { + . (Join-Path $directory 'Invoke-UiCaptureWorker.ps1') -OwnedRoot $ScratchRoot -DefinitionsOnly + $runDirectory=Join-Path $ScratchRoot 'cleanup-model' + $job=[pscustomobject]@{sceneAdapter='S_MATCHED_V12_DPI_V1'} + function Get-CapWorkerWindowIdentity {param($Phase,$SceneName) return @{}} + function Get-CapWorkerWireBinding {return $binding} + function Start-CapManagedProcess {return [pscustomobject]@{Identity=@{Role='MODEL_NO_PROCESS'}}} + function Start-CapGraphCodeApp {throw 'ORIGINAL_WIRE_PRIMARY_MODEL'} + function Stop-CapManagedProcess {throw 'CLEANUP_REFUSAL_MODEL'} + $failure=$null + try {Invoke-CapWireCompletionSupplement -StubDaemonPath 'MODEL_NEVER_EXECUTED'} + catch {$failure=$_} + Assert-WireCase ($failure.Exception.Message -ceq 'ORIGINAL_WIRE_PRIMARY_MODEL' -and + $cleanupFailures.Count -eq 1 -and $cleanupFailures[0].Contains('CLEANUP_REFUSAL_MODEL')) ` + "Worker lost its original failure or falsely reported cleanup success: $failure; cleanup=$cleanupFailures" + } + } + } +} finally { + foreach ($window in @($content,$hostWindow,$root)) { + if ($window -ne [IntPtr]::Zero -and -not [WireFixture]::DestroyWindow($window)) { + throw 'Harmless fixture window cleanup failed.' + } + if (Get-Variable self -ErrorAction SilentlyContinue) {$self.Dispose()} + } +} +$failed=@($results|Where-Object passed -EQ $false).Count +$report=[ordered]@{suite='WireTargeting';executed=$results.Count;passed=($results.Count-$failed) + failed=$failed;skipped=0;productLaunches=0;providerLaunches=0;cases=@($results)} +[IO.File]::WriteAllText((Join-Path $ScratchRoot 'report.json'),($report|ConvertTo-Json -Depth 30), + [Text.UTF8Encoding]::new($false)) +$report|ConvertTo-Json -Depth 30 | Set-Content -LiteralPath (Join-Path $ScratchRoot 'summary.json') +[pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress +foreach ($result in @($results|Where-Object passed -EQ $false)) {$result|ConvertTo-Json -Compress|Write-Host} +if ($failed -gt 0) {throw "$failed behavioral wire targeting contracts failed."} +if ($results.Count -ne $(if ($LegacyBehavior) {2} else {56})) {throw 'Wire targeting executed count differs.'} diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index 995887eb..8de9b5d0 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -121,6 +121,9 @@ production startup refusal before launch, and initial/placed identity guard drift with modeled metrics. Its accepted startup intercepts the launcher without starting a process; it is not native-window evidence. ValidationRunner requires all 57 with zero skips, alongside the 18 import and unchanged 182/185 baseline. +The W1 targeting repair expands that window-binding suite to **62** contracts: +five additional startup refusals require an explicit nonnull, source/hash-bound +source/target plan before either the stub or app can launch. `Tests\ActionTopologyImport.Tests.ps1` adds **16 separately counted contracts** for production import, lexical top-level scope, exact topology copies, layout/ @@ -131,6 +134,54 @@ contracts also exercise all five qualified topology/seed preparation paths through the unmodified production entry dry run. ValidationRunner requires all 16 with zero skips without replacing the existing suites or baseline cases. +### W1 target selection and native form observation + +The prepared action plan includes `wireTargetBinding` for WireCompletion and +FinalBoundedActions. It binds the owned project path, exact source and target +UUIDs and stable UIA IDs, source-derived title-band geometry, and hashes of +GraphCanvas, DesignTokens, NativeForms and SheetFramework. Unknown geometry +refuses; there is no generic midpoint or offset fallback. After arming Wire, +the worker reacquires immutable owned card/control observations twice, checks +the exact armed status and source-bound window metrics, and refuses identity, +runtime-ID, bounds, scale, or overlap drift. The selected point is inside the +title band above the source-defined inline actions, not the card midpoint. + +The physical screen-to-client conversion and posted mouse-message dispatch +enter per-monitor-v2 awareness and restore the caller's context in `finally`. +W1 requires the native recipient to equal the held app's intended root HWND. +The intent/result trace records source/target binding, safe bounds, screen and +client points, packed LPARAM, recipient PID/HWND, foreground HWND, DPI context, +and individual message-post results. These are **posted window messages**, not +physical cursor movement or a W6 hover result. + +Native modal observation now enumerates the held process's window hierarchy. +It requires exactly one visible GraphCodeSheetHost owned by the app root and +one visible GraphCodeNativeForm descendant through the viewport. The Create +button belongs to the sheet footer, so UIA observation/invocation uses that +owned host rather than the child content. The trace retains pre/post hierarchy +censuses and observed modal controls, including on timeout. Missing hosts or +not-yet-created content are polled within the clipped action deadline; +ambiguity, foreign identity, replacement, or native query errors refuse. +The original action exception is retained if cleanup also fails; launcher EOF +is never relabeled target-private pipe EOF. + +`Tests\WireTargeting.Tests.ps1` adds **56 separately counted contracts**, required +by ValidationRunner with zero skips. They exercise the actual shared production +target/oracle/stability/dispatch helpers with modeled negative cases and a +harmless native owned popup/child fixture. The native fixture checks actual +PMv2 conversion, message acceptance and awareness restoration on the current +desktop; it is not GraphCode/provider or product-action evidence. Dispatch +failure cases inject rejected native-post results into the same dispatch loop. +The preserved legacy behavioral run demonstrates the top-level-only child miss +and midpoint/Mark collision separately from passing results. + +The production `-DryRun` also validates `wireTargetBinding` before launching its +harmless child and records `wireAlgorithmPreflight`, which exercises the shared +target and descendant-oracle algorithms with an explicitly labeled **model +fixture**. It does not create native fixture windows or validate product +behavior. Old plans lacking the target binding must be prepared again; no +historical failed action is retried or reconstructed by this repair. + ## Live transition rehearsal `LIVE_TRANSITION_REHEARSAL` is a no-lease, non-evidence mode for exercising the diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 95e0f982..16b6781a 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -80,12 +80,8 @@ public static class GraphCodeUiaGateState { return window != IntPtr.Zero && GetForegroundWindow() == window; } public static bool PostPointerClickScreen(IntPtr window, int screenX, int screenY) { - var point = new Point { X = screenX, Y = screenY }; - if (window == IntPtr.Zero || !ScreenToClient(window, ref point)) return false; - var packed = (IntPtr)((point.Y << 16) | (point.X & 0xffff)); - return PostMessage(window, 0x0200, UIntPtr.Zero, packed) && - PostMessage(window, 0x0201, (UIntPtr)1, packed) && - PostMessage(window, 0x0202, UIntPtr.Zero, packed); + CapNative.PostPointerClickScreen(window, screenX, screenY, false); + return true; } public static long[] MovePointerScreen(int screenX, int screenY) { if (!SetCursorPos(screenX, screenY)) return new long[0]; @@ -132,9 +128,6 @@ public static class GraphCodeUiaGateState { } } '@ -if (-not ('GraphCodeUiaGateState' -as [type])) { - Add-Type -TypeDefinition $helperSource -} $nativeSource = @' using System; @@ -169,6 +162,22 @@ public sealed class CapCaptureResult { public uint Dpi { get; set; } } +public sealed class CapWindowNode { + public long Handle, Parent, Owner; + public int ProcessId; + public string ClassName, Name; + public bool Visible, Enabled; + public int[] Bounds; +} + +public sealed class CapPointerReceipt { + public long Window, Recipient, Foreground, PackedLParam, CursorWindow; + public int ProcessId, RecipientProcessId, ScreenX, ScreenY, ClientX, ClientY, CursorX, CursorY; + public long DpiContext; + public bool MovePosted, DownPosted, UpPosted; + public string Delivery = "POSTED_WINDOW_MESSAGES_NOT_PHYSICAL_POINTER"; +} + public static class CapNative { private const uint WAIT_OBJECT_0 = 0; private const uint WAIT_TIMEOUT = 0x102; @@ -236,6 +245,23 @@ public static class CapNative { private static extern bool GetMonitorInfo(IntPtr monitor, ref NativeMonitorInfo info); [DllImport("user32.dll", SetLastError=true)] private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); + [DllImport("user32.dll")] private static extern IntPtr GetThreadDpiAwarenessContext(); + [DllImport("user32.dll", SetLastError=true)] private static extern bool EnumWindows(WindowCallback callback, IntPtr value); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr value); + private delegate bool WindowCallback(IntPtr window, IntPtr value); + [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] private static extern IntPtr GetWindow(IntPtr window, uint command); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] private static extern bool IsWindowEnabled(IntPtr window); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern int GetClassName(IntPtr window, StringBuilder name, int size); + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr SendMessageTimeoutW(IntPtr window, uint message, UIntPtr count, + StringBuilder text, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll", SetLastError=true)] private static extern bool ScreenToClient(IntPtr window, ref NativePoint point); + [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(NativePoint point); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", SetLastError=true)] private static extern bool GetCursorPos(out NativePoint point); + [DllImport("user32.dll", SetLastError=true)] private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern bool WaitNamedPipeW(string name, uint timeout); @@ -246,9 +272,127 @@ public static class CapNative { return previous; } - private static void RestoreDpiAwareness(IntPtr previous) { - if (SetThreadDpiAwarenessContext(previous) == IntPtr.Zero) - throw new InvalidOperationException("Cannot restore the capture thread DPI awareness: " + Marshal.GetLastWin32Error()); + private static void RestoreDpiAwareness(IntPtr previous, Exception primary = null) { + if (SetThreadDpiAwarenessContext(previous) == IntPtr.Zero) { + var error = new InvalidOperationException("Cannot restore the capture thread DPI awareness: " + Marshal.GetLastWin32Error()); + if (primary != null) throw new AggregateException("Native operation and DPI restoration failed.", primary, error); + throw error; + } + } + + public static CapWindowNode[] WindowHierarchy(int expectedPid) { + var rows = new System.Collections.Generic.List(); + IntPtr previous = EnterPerMonitorV2(); + Exception primary = null; + try { + Exception failure = null; + var clock = System.Diagnostics.Stopwatch.StartNew(); + WindowCallback record = delegate(IntPtr window, IntPtr value) { + try { + if (clock.ElapsedMilliseconds > 2000) throw new InvalidOperationException("Owned window census exceeded 2000ms."); + uint pid; + if (GetWindowThreadProcessId(window, out pid) == 0) + throw new InvalidOperationException("Window census process query failed."); + if (pid != expectedPid) return true; + var cls = new StringBuilder(256); + var name = new StringBuilder(512); + if (GetClassName(window, cls, cls.Capacity) == 0) + throw new InvalidOperationException("Window census class query failed."); + UIntPtr textResult; + if (SendMessageTimeoutW(window, 0x000D, (UIntPtr)name.Capacity, name, 2, 50, out textResult) == IntPtr.Zero) + throw new InvalidOperationException("Window census text query failed/timed out for HWND " + window); + NativeRect bounds; + if (!GetWindowRect(window, out bounds)) + throw new InvalidOperationException("Window census bounds query failed."); + rows.Add(new CapWindowNode { Handle=window.ToInt64(), Parent=GetAncestor(window, 1).ToInt64(), + Owner=GetWindow(window, 4).ToInt64(), ProcessId=(int)pid, ClassName=cls.ToString(), Name=name.ToString(), + Visible=IsWindowVisible(window), Enabled=IsWindowEnabled(window), + Bounds=new int[] {bounds.Left,bounds.Top,bounds.Right,bounds.Bottom} }); + return true; + } catch (Exception error) { failure=error; return false; } + }; + bool complete = EnumWindows(delegate(IntPtr window, IntPtr value) { + if (failure != null) return false; + uint pid; + GetWindowThreadProcessId(window, out pid); + if (pid == expectedPid) { + if (!record(window, value)) return false; + EnumChildWindows(window, record, value); + } + return failure == null; + }, IntPtr.Zero); + if (failure != null) { + failure.Data["ObservedRows"]=rows.ToArray(); + throw failure; + } + if (!complete) throw new InvalidOperationException("Owned EnumWindows failed: " + Marshal.GetLastWin32Error()); + return rows.ToArray(); + } catch (Exception error) { primary=error; throw; } + finally { RestoreDpiAwareness(previous, primary); } + } + + public static int[] ConvertPointerScreen(IntPtr window, int screenX, int screenY) { + IntPtr previous=EnterPerMonitorV2(); + Exception primary=null; + try { + if (screenX < short.MinValue || screenX > short.MaxValue || screenY < short.MinValue || screenY > short.MaxValue) + throw new InvalidOperationException("Pointer screen coordinates exceed signed native conversion range."); + var point=new NativePoint { X=screenX, Y=screenY }; + if (!ScreenToClient(window, ref point)) + throw new InvalidOperationException("ScreenToClient failed: " + Marshal.GetLastWin32Error() + + "; HWND=" + window + "; screen=" + screenX + "," + screenY + "; DPI=PMv2"); + if (point.X < short.MinValue || point.X > short.MaxValue || point.Y < short.MinValue || point.Y > short.MaxValue) + throw new InvalidOperationException("Pointer client coordinates exceed signed LPARAM range."); + return new int[] { point.X, point.Y }; + } catch (Exception error) { primary=error; throw; } + finally { RestoreDpiAwareness(previous, primary); } + } + + public static CapPointerReceipt PostPointerClickScreen(IntPtr window, int screenX, int screenY, bool requireRecipient = true) { + IntPtr previous = EnterPerMonitorV2(); + Exception primary=null; + try { + NativePoint cursor; + if (!GetCursorPos(out cursor)) + throw new InvalidOperationException("Pointer cursor observation failed: " + Marshal.GetLastWin32Error()); + var point = new NativePoint { X=screenX, Y=screenY }; + var recipient = WindowFromPoint(point); + uint pid=0, recipientPid=0; + uint intendedThread = window == IntPtr.Zero ? 0 : GetWindowThreadProcessId(window, out pid); + uint recipientThread = recipient == IntPtr.Zero ? 0 : GetWindowThreadProcessId(recipient, out recipientPid); + if (intendedThread == 0 || + (requireRecipient && (recipient != window || recipientThread == 0 || recipientPid != pid))) + throw new InvalidOperationException("Pointer recipient differs from intended owned HWND; intended=" + + window + "; intendedPID=" + pid + "; recipient=" + recipient + "; recipientPID=" + recipientPid + + "; screen=" + screenX + "," + screenY + "; foreground=" + GetForegroundWindow() + + "; cursor=" + cursor.X + "," + cursor.Y + "; cursorWindow=" + WindowFromPoint(cursor) + "; DPI=PMv2."); + var client=ConvertPointerScreen(window, screenX, screenY); + point.X=client[0]; point.Y=client[1]; + long packed = (point.Y & 0xffff) << 16 | (point.X & 0xffff); + var result = new CapPointerReceipt { Window=window.ToInt64(), Recipient=recipient.ToInt64(), + Foreground=GetForegroundWindow().ToInt64(), ProcessId=(int)pid, RecipientProcessId=(int)recipientPid, + ScreenX=screenX, ScreenY=screenY, ClientX=point.X, ClientY=point.Y, PackedLParam=packed, + DpiContext=GetThreadDpiAwarenessContext().ToInt64(), CursorX=cursor.X, CursorY=cursor.Y, + CursorWindow=WindowFromPoint(cursor).ToInt64() }; + DispatchPointerMessages(result, delegate(uint message, uint keys, long position) { + return PostMessage(window, message, (UIntPtr)keys, new IntPtr(position)); + }); + return result; + } catch (Exception error) { primary=error; throw; } + finally { RestoreDpiAwareness(previous, primary); } + } + + public static void DispatchPointerMessages(CapPointerReceipt result, Func poster) { + result.MovePosted = poster(0x0200, 0, result.PackedLParam); + if (result.MovePosted) result.DownPosted = poster(0x0201, 1, result.PackedLParam); + if (result.DownPosted) result.UpPosted = poster(0x0202, 0, result.PackedLParam); + if (!result.MovePosted || !result.DownPosted || !result.UpPosted) + throw new InvalidOperationException("Native pointer dispatch failed: " + + Marshal.GetLastWin32Error() + "; HWND=" + result.Window + "; screen=" + result.ScreenX + "," + result.ScreenY + + "; client=" + result.ClientX + "," + result.ClientY + "; LPARAM=" + result.PackedLParam + "; move/down/up=" + + result.MovePosted + "/" + result.DownPosted + "/" + result.UpPosted + + "; recipient=" + result.Recipient + "; foreground=" + result.Foreground + "; DPI=" + result.DpiContext + + "; cursor=" + result.CursorX + "," + result.CursorY + "; cursorWindow=" + result.CursorWindow); } private static long FileTime(NativeFileTime value) { @@ -496,7 +640,7 @@ public static class CapNative { '@ if (-not ('CapNative' -as [type])) { - Add-Type -TypeDefinition $nativeSource -ReferencedAssemblies @( + Add-Type -TypeDefinition ($nativeSource + [regex]::Replace($helperSource,'(?m)^using .*;\r?$','')) -ReferencedAssemblies @( [System.Drawing.Bitmap].Assembly.Location, [System.Drawing.Graphics].Assembly.Location, [System.Drawing.Color].Assembly.Location, @@ -1752,6 +1896,190 @@ function Find-CapAutomationElement { } } +function Get-CapWireTargetBinding { + param([Parameter(Mandatory)][string]$Repository,[Parameter(Mandatory)][string]$OwnedRoot) + $sources=[ordered]@{} + foreach ($file in @('GraphCanvas.zig','DesignTokens.zig','NativeForms.zig','SheetFramework.zig')) { + $path=Join-Path $Repository "graphcode-windows\src\$file" + $sources[$file]=[IO.File]::ReadAllText($path) + } + $canvas=$sources['GraphCanvas.zig'] + $unwired=[regex]::Match($canvas,'(?s)fn unwiredActionBoundsForZoom\(.*?\n\}') + $numbers=@([regex]::Matches($unwired.Value,'scaledValue\((\d+), zoom\)') | + ForEach-Object {[int]$_.Groups[1].Value}) + $width=[regex]::Match($sources['DesignTokens.zig'],'loop_card_width: i32 = (\d+);') + $height=[regex]::Match($sources['DesignTokens.zig'],'loop_card_height: i32 = (\d+);') + if (($numbers -join ',') -cne '11,31,84,51,90,31,176,51' -or + $width.Groups[1].Value -cne '250' -or $height.Groups[1].Value -cne '96' -or + $sources['NativeForms.zig'] -notmatch 'c\.WS_CHILD \| c\.WS_VISIBLE \| c\.WS_CLIPCHILDREN' -or + $sources['NativeForms.zig'] -notmatch 'sheet\.viewport,' -or + $sources['NativeForms.zig'] -notmatch 'const class_name = .*"GraphCodeNativeForm"' -or + $sources['SheetFramework.zig'] -notmatch 'const host_class = .*"GraphCodeSheetHost"') { + throw 'Wire target source geometry or sheet hierarchy changed; no guessed fallback.' + } + $project=Join-Path ([IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\')) 'fixtures\wire-project' + $source='60000000-0000-4000-8000-000000000001' + $target='60000000-0000-4000-8000-000000000002' + return [pscustomobject]@{ + schemaVersion=1L;scene='s-goal-unwired-unselected';phase='wire-completion' + projectPath=$project;sourceNodeId=$source;targetNodeId=$target + sourceCardId=(Get-CapMatchedAutomationId "project-card:$project`:$source") + targetCardId=(Get-CapMatchedAutomationId "project-card:$project`:$target") + wireIdentity="wire-node:$project`:$source" + wireId=(Get-CapMatchedAutomationId "wire-node:$project`:$source") + sourceName='Wire source';targetName='Wire target' + cardWH=@(250,96) + # The title band ends before the first source-defined inline action. + safeLogicalBounds=@($numbers[0],0,$numbers[6],$numbers[1]) + sheetClass='GraphCodeSheetHost';contentClass='GraphCodeNativeForm' + sourceHashes=[pscustomobject]([ordered]@{ + canvas=(Get-FileHash (Join-Path $Repository 'graphcode-windows\src\GraphCanvas.zig')).Hash + tokens=(Get-FileHash (Join-Path $Repository 'graphcode-windows\src\DesignTokens.zig')).Hash + forms=(Get-FileHash (Join-Path $Repository 'graphcode-windows\src\NativeForms.zig')).Hash + sheets=(Get-FileHash (Join-Path $Repository 'graphcode-windows\src\SheetFramework.zig')).Hash + }) + } +} + +function Assert-CapWireTargetBinding { + param([AllowNull()]$Binding,[Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$OwnedRoot) + $expected=Get-CapWireTargetBinding -Repository $Repository -OwnedRoot $OwnedRoot + if ($null -eq $Binding -or -not [Text.Json.Nodes.JsonNode]::DeepEquals( + [Text.Json.Nodes.JsonNode]::Parse(($Binding|ConvertTo-Json -Depth 20 -Compress)), + [Text.Json.Nodes.JsonNode]::Parse(($expected|ConvertTo-Json -Depth 20 -Compress)))) { + throw 'Wire planned source/target binding differs or is missing.' + } + return $Binding +} + +function Get-CapWireTargetObservation { + param([Parameter(Mandatory)]$Binding,[Parameter(Mandatory)][object[]]$Rows, + [Parameter(Mandatory)][int]$ExpectedProcessId,[Parameter(Mandatory)][uint32]$Dpi) + $null=Assert-CapUiaProcessIds -ExpectedProcessId $ExpectedProcessId ` + -ObservedProcessIds @($Rows|ForEach-Object {[int]$_.ProcessId}) + $source=@($Rows|Where-Object AutomationId -CEQ $Binding.sourceCardId) + $target=@($Rows|Where-Object AutomationId -CEQ $Binding.targetCardId) + $wire=@($Rows|Where-Object AutomationId -CEQ $Binding.wireId) + $status=@($Rows|Where-Object AutomationId -CEQ 'status') + if ($source.Count -ne 1 -or $target.Count -ne 1 -or $wire.Count -ne 1 -or + $source[0].Name -cne $Binding.sourceName -or $target[0].Name -cne $Binding.targetName -or + $wire[0].Name -cne 'Wire it up' -or $status.Count -ne 1 -or + $status[0].Name -cne 'Choose a target loop to wire this entry') { + throw 'Wire source/target identity or active Wire mode differs.' + } + $scale=$Dpi/96.0 + foreach ($card in @($source[0],$target[0])) { + $b=@($card.Bounds) + if ($card.IsOffscreen -or -not $card.IsEnabled -or $b.Count -ne 4 -or + @($b|Where-Object {-not [double]::IsFinite($_)}).Count -ne 0 -or + [Math]::Abs(($b[2]-$b[0])-$Binding.cardWH[0]*$scale) -gt 1 -or + [Math]::Abs(($b[3]-$b[1])-$Binding.cardWH[1]*$scale) -gt 1) { + throw 'Wire card bounds/scale are stale or invalid.' + } + } + $bounds=@($target[0].Bounds) + $safe=@( + ($bounds[0]+$Binding.safeLogicalBounds[0]*$scale), + ($bounds[1]+$Binding.safeLogicalBounds[1]*$scale), + ($bounds[0]+$Binding.safeLogicalBounds[2]*$scale), + ($bounds[1]+$Binding.safeLogicalBounds[3]*$scale)) + $point=@([int][Math]::Floor(($safe[0]+$safe[2])/2),[int][Math]::Floor(($safe[1]+$safe[3])/2)) + foreach ($row in $Rows) { + if ($row.AutomationId -ceq $Binding.targetCardId -or $row.AutomationId -cin @('graph','status')) {continue} + $b=@($row.Bounds) + if (-not $row.IsOffscreen -and $b.Count -eq 4 -and $point[0] -ge $b[0] -and + $point[0] -lt $b[2] -and $point[1] -ge $b[1] -and $point[1] -lt $b[3]) { + throw "Unsafe wire target point overlaps another card/control: $($row.AutomationId)" + } + } + if ($point[0] -le $bounds[0] -or $point[0] -ge $bounds[2] -or + $point[1] -le $bounds[1] -or $point[1] -ge $bounds[3]) {throw 'Unsafe wire target point outside card.'} + return [pscustomobject]@{binding=$Binding;source=$source[0];target=$target[0] + mode=$status[0].Name;safeBounds=$safe;screenPoint=$point;dpi=$Dpi} +} + +function Resolve-CapOwnedSheet { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rows, + [Parameter(Mandatory)][long]$RootHandle,[Parameter(Mandatory)][int]$ExpectedProcessId, + [string]$SheetClass='GraphCodeSheetHost',[string]$ContentClass='GraphCodeNativeForm') + $null=Assert-CapUiaProcessIds -ExpectedProcessId $ExpectedProcessId ` + -ObservedProcessIds @($Rows|ForEach-Object {[int]$_.ProcessId}) + $hosts=@($Rows|Where-Object {$_.ClassName -ceq $SheetClass -and $_.Owner -eq $RootHandle -and $_.Visible}) + if ($hosts.Count -eq 0) {return $null} + if ($hosts.Count -ne 1) {throw 'Ambiguous owned sheet hosts.'} + $hostRow=$hosts[0] + $contents=@(foreach ($row in $Rows) { + if ($row.ClassName -cne $ContentClass -or -not $row.Visible) {continue} + $parent=[long]$row.Parent + $seen=[Collections.Generic.HashSet[long]]::new() + while ($parent -ne 0 -and $parent -ne $hostRow.Handle) { + if (-not $seen.Add($parent)) {throw 'Cyclic sheet hierarchy.'} + $parents=@($Rows|Where-Object Handle -EQ $parent) + if ($parents.Count -ne 1) {throw 'Missing or ambiguous sheet ancestor.'} + $parent=[long]$parents[0].Parent + } + if ($parent -eq $hostRow.Handle) {$row} + }) + if ($contents.Count -eq 0) {return $null} + if ($contents.Count -ne 1) {throw 'Missing or ambiguous owned sheet child content.'} + return [pscustomobject]@{host=$hostRow;content=$contents[0]} +} + +function Assert-CapWireTargetStable { + param([Parameter(Mandatory)]$First,[Parameter(Mandatory)]$Second, + [Parameter(Mandatory)][int[]]$RootBefore,[Parameter(Mandatory)][int[]]$RootAfter) + if ($RootBefore.Count -eq 0 -or ($RootBefore -join ',') -cne ($RootAfter -join ',') -or + ($First|ConvertTo-Json -Depth 20 -Compress) -cne ($Second|ConvertTo-Json -Depth 20 -Compress)) { + throw 'Wire target element/root identity or bounds drifted before dispatch.' + } +} + +function Get-CapWireAlgorithmPreflight { + param([Parameter(Mandatory)]$Binding,[Parameter(Mandatory)][int]$ExpectedProcessId) + $rows=@(foreach ($spec in @( + @($Binding.sourceCardId,$Binding.sourceName,@(0,0,250,96)), + @($Binding.targetCardId,$Binding.targetName,@(300,0,550,96)), + @($Binding.wireId,'Wire it up',@(11,31,84,51)), + @('fixture-target-mark','Mark as entry',@(390,31,476,51)), + @('status','Choose a target loop to wire this entry',@(0,150,500,180)) + )) { + [pscustomobject]@{AutomationId=$spec[0];Name=$spec[1];Bounds=$spec[2] + ProcessId=$ExpectedProcessId;IsEnabled=$true;IsOffscreen=$false} + }) + $target=Get-CapWireTargetObservation -Binding $Binding -Rows $rows ` + -ExpectedProcessId $ExpectedProcessId -Dpi 96 + $sheetRows=@( + [pscustomobject]@{Handle=2L;Owner=1L;Parent=0L;ProcessId=$ExpectedProcessId + ClassName=$Binding.sheetClass;Visible=$true}, + [pscustomobject]@{Handle=3L;Owner=0L;Parent=2L;ProcessId=$ExpectedProcessId + ClassName='fixture-viewport';Visible=$true}, + [pscustomobject]@{Handle=4L;Owner=0L;Parent=3L;ProcessId=$ExpectedProcessId + ClassName=$Binding.contentClass;Visible=$true}) + $sheet=Resolve-CapOwnedSheet -Rows $sheetRows -RootHandle 1 -ExpectedProcessId $ExpectedProcessId + if ($null -eq $sheet) {throw 'Wire algorithm fixture did not resolve owned sheet content.'} + return [pscustomobject]@{kind='MODEL_FIXTURE_SHARED_PRODUCTION_ALGORITHMS_NOT_NATIVE_ACTION' + target=$target;sheet=$sheet;runtimeWindowEvidence=$false;productLaunches=0;providerLaunches=0} +} + +function Get-CapOwnedWindowHierarchy { + param([Parameter(Mandatory)][IntPtr]$RootHandle,[Parameter(Mandatory)]$ProcessIdentity) + $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity + try {$rows=@([CapNative]::WindowHierarchy($ProcessIdentity.Pid))} + catch { + $cause=$_.Exception + while ($cause.InnerException) {$cause=$cause.InnerException} + Write-CapTrace -Command 'OwnedWindowCensusFailure' -Parameters @{ + root=[long]$RootHandle;error=$cause.Message + observedRows=($cause.Data['ObservedRows']|ConvertTo-Json -Depth 10 -Compress)} + throw + } + $null=Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters @{ + root=[long]$RootHandle;rows=($rows|ConvertTo-Json -Depth 10 -Compress)} + return $rows +} + function Get-CapDirectChildren { [CmdletBinding()] param([Parameter(Mandatory)][System.Windows.Automation.AutomationElement]$Parent) @@ -6152,6 +6480,8 @@ function Assert-CapNoWorkerTransientArtifacts { } Export-ModuleMember -Function @( + 'Get-CapWireTargetBinding','Assert-CapWireTargetBinding','Get-CapWireTargetObservation', + 'Resolve-CapOwnedSheet','Get-CapOwnedWindowHierarchy','Assert-CapWireTargetStable','Get-CapWireAlgorithmPreflight', 'Get-CapMatchedSceneNames','Get-CapMatchedFocusedSceneNames','Get-CapMatchedFocusedClassification', 'Get-CapMatchedCapturePlan', 'Get-CapActionWindowBindings','Assert-CapActionWindowBindings',