From 5a37cd6e3b48b39633ada7a7ccaddb8ae1910efa Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 16 Oct 2026 19:43:59 -0700 Subject: [PATCH] Replay terminal selector evidence on the merged journal core Replay the accepted selector-refusal patch from dea7de2a onto 77ec2213. Preserve the accepted worker wait and selector functions alongside the merged journal policy and harness. Reconcile only suite registration and documentation to 85 ordinary and 88 real-seed cases. Refs #848 Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85eae846-dd85-4ac2-8eff-3622e270e574 --- Tools/windows/Invoke-UiCaptureWorker.ps1 | 12 +- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 499 +++++++++++++++++- .../windows/Tests/ValidationRunner.Tests.ps1 | 2 +- Tools/windows/UiCaptureDriver.psm1 | 323 +++++++++++- windows-tests/ui-scenarios/README.md | 101 +++- 5 files changed, 922 insertions(+), 15 deletions(-) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 1da3af1e..be922fe9 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -495,18 +495,28 @@ function Wait-CapVisibleElement { ) Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $lastSnapshot = $null + $lastPollUtc = $null + try { while ([DateTime]::UtcNow -lt $leaseDeadlineUtc) { Assert-CapWorkerDeadline "UIA element $Name" + $lastPollUtc = [DateTime]::UtcNow try { return Find-CapAutomationElement -RootHandle $Window -ProcessIdentity $ProcessIdentity ` -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix ` - -AllowOffscreen:$AllowOffscreen + -AllowOffscreen:$AllowOffscreen -RefusalSnapshot ([ref]$lastSnapshot) } catch { if ($_.Exception.Message -notmatch 'found=0\.') { throw } Start-Sleep -Milliseconds 100 } } throw "UIA element '$Name' did not become available before the reserved deadline." + } catch { + Write-CapTerminalSelectorRefusal -Snapshot $lastSnapshot -Refusal $_ -PollUtc $lastPollUtc ` + -DeadlineUtc $leaseDeadlineUtc -Window $Window -ProcessIdentity $ProcessIdentity ` + -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix -AllowOffscreen:$AllowOffscreen + throw + } } function Invoke-CapSceneAction { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 97c61999..f7184240 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -10,6 +10,7 @@ param( [switch]$JournalFirstOpenContractOnly, [switch]$JournalObservationContractOnly, [switch]$PreflightReportContractOnly, + [switch]$SelectorRefusalContractOnly, [string]$PrebuiltFixtureManifest = '', [string]$PrebuiltFixtureManifestSha256 = '', [switch]$RequireRealSeed, @@ -2216,6 +2217,501 @@ function Invoke-CapPreflightReportContract { [Text.Encoding]::UTF8.GetBytes($definition.Extent.Text))) } } +function Invoke-CapSelectorRefusalContracts { + function New-SelectorCandidate { + param([string]$CandidateName = 'Alpha', [string]$Id = 'open-project-alpha', + [int]$CandidateProcessId = 4242, [bool]$Offscreen = $false, [double]$Width = 200) + return [pscustomobject]@{ Name = $CandidateName; AutomationId = $Id; ProcessId = $CandidateProcessId + IsOffscreen = $Offscreen; BoundingRectangle = [pscustomobject]@{ + Left = 12.0; Top = 20.0; Width = $Width; Height = 24.0 }; ControlType = 'ControlType.Button' } + } + function Read-SelectorEvidence { + param([string]$Path) + $rows = @(Get-Content -LiteralPath $Path | ForEach-Object { ConvertFrom-Json $_ -DateKind String } | + Where-Object command -CEQ 'CapSelectorRefusal') + Assert-CapTest ($rows.Count -eq 1) 'Expected exactly one retained refusal diagnostic.' + return ConvertFrom-Json $rows[0].parameters.diagnosticJson -DateKind String + } + Invoke-CapTest 'selector refusal zero retains typed stage and candidate evidence' { + $candidate = [pscustomobject]@{ + Name = 'Beta'; AutomationId = 'open-project-beta'; ProcessId = 4242 + IsOffscreen = $false + BoundingRectangle = [pscustomobject]@{ Left = 12.0; Top = 20.0; Width = 200.0; Height = 24.0 } + ControlType = 'ControlType.Button' + } + $message = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates @($candidate) -ExpectedProcessId 4242 ` + -Name Alpha -AutomationIdPrefix 'open-project-' + } "Expected exactly one visible UIA element Name='Alpha' id='' prefix='open-project-'; found=0." + $records = @(Get-Content -LiteralPath (Join-Path $scratch 'test-calls.jsonl') | + ForEach-Object { ConvertFrom-Json $_ -DateKind String } | + Where-Object command -CEQ 'CapSelectorRefusal') + Assert-CapTest ($records.Count -eq 1) ` + 'Existing zero-match refusal occurred, but retained CapSelectorRefusal evidence is missing.' + $evidence = ConvertFrom-Json $records[0].parameters.diagnosticJson -DateKind String + Assert-CapTest ($evidence.schema -ceq 'CapSelectorRefusal/1' -and + $evidence.refusal.message -ceq $message -and $evidence.expectedProcessId -eq 4242 -and + $evidence.stageCounts.total -eq 1 -and $evidence.stageCounts.processOwned -eq 1 -and + $evidence.stageCounts.nameMatch -eq 0 -and $evidence.stageCounts.prefixMatch -eq 0 -and + $evidence.stageCounts.visible -eq 0 -and $evidence.stageCounts.bounds -eq 0) ` + 'Retained refusal does not bind the original message and cumulative exact selector stages.' + Assert-CapTest ($evidence.observedProcessIds.Count -eq 1 -and + $evidence.observedProcessIds[0] -is [long] -and $evidence.observedProcessIds[0] -eq 4242 -and + $evidence.candidates.Count -eq 1 -and $evidence.candidates[0].Name.value -ceq 'Beta' -and + $evidence.candidates[0].AutomationId.value -ceq 'open-project-beta' -and + $evidence.candidates[0].ProcessId.value -is [long] -and + $evidence.candidates[0].IsOffscreen.value -is [bool] -and + $evidence.candidates[0].BoundingRectangle.value.Width -eq 200 -and + $evidence.candidates[0].ControlType.value -ceq 'ControlType.Button') ` + 'Candidate evidence must contain actual typed scalar values, not CLR type names.' + return @{ originalRefusal = $message; evidence = $evidence; mode = 'Synthetic candidates; actual resolver and retained trace sink; no desktop.' } + } + Invoke-CapTest 'selector refusal ambiguity preserves original outcome and held context' { + $path = Join-Path $scratch 'selector-ambiguous.jsonl' + Set-CapTracePath $path + $context = @{ RootHandle = [IntPtr]77; RootProcessId = 4242; Scope = 'Descendants'; ParameterSet = 'Window' + ProcessIdentity = [pscustomobject]@{ Pid = 4242; CreationFileTimeUtc = 123456789L + ExpectedImagePath = 'C:\owned\fixture.exe'; Role = 'synthetic-held-generation' } } + $message = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates @((New-SelectorCandidate), (New-SelectorCandidate)) ` + -ExpectedProcessId 4242 -Name Alpha -AutomationIdPrefix 'open-project-' -RefusalContext $context + } "found=2." + $data = Read-SelectorEvidence $path + Assert-CapTest ($data.refusal.message -ceq $message -and $data.stageCounts.bounds -eq 2 -and + $data.heldContext.rootHandle -eq 77 -and $data.heldContext.identity.Pid.value -eq 4242 -and + $data.heldContext.identity.CreationFileTimeUtc.value -eq 123456789L -and + $data.stageCountsComplete -and $data.observedProcessIdRawCount -eq 2 -and + $data.observedProcessIdDistinctCount -eq 1) 'Ambiguity, held generation or actual PID multiplicity changed.' + $legacy = @(Get-Content $path | ForEach-Object { ConvertFrom-Json $_ } | + Where-Object command -CEQ 'Resolve-CapAutomationCandidate') + Assert-CapTest (-not $legacy[0].parameters.PSObject.Properties['RefusalContext']) 'Diagnostic context leaked into legacy trace.' + return $data + } + Invoke-CapTest 'selector refusal ownership records the full set without adopting foreign candidates' { + $path = Join-Path $scratch 'selector-ownership.jsonl' + Set-CapTracePath $path + $message = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates @( + (New-SelectorCandidate -CandidateProcessId 9999), + (New-SelectorCandidate -Offscreen $true), (New-SelectorCandidate -CandidateName Beta) + ) -ExpectedProcessId 4242 -Name Alpha -AutomationIdPrefix 'open-project-' + } 'UIA subtree process 9999 does not match owned PID 4242.' + $data = Read-SelectorEvidence $path + Assert-CapTest ($data.refusal.message -ceq $message -and $data.stageCounts.total -eq 3 -and + $data.stageCounts.processOwned -eq 2 -and $data.stageCounts.nameMatch -eq 1 -and + $data.stageCounts.prefixMatch -eq 1 -and $data.stageCounts.visible -eq 0 -and + ($data.observedProcessIds -join ',') -ceq '9999,4242' -and $data.observedProcessIdRawCount -eq 3 -and + $data.heldContext.status -ceq 'NotObserved') 'Ownership refusal lost actual full-set stages/PIDs or invented held context.' + return $data + } + Invoke-CapTest 'selector refusal stages preserve exact optional ID prefix and offscreen rules' { + $candidates = @( + (New-SelectorCandidate -CandidateName Beta), (New-SelectorCandidate -Id unrelated), + (New-SelectorCandidate -Offscreen $true), (New-SelectorCandidate -Offscreen $true), + (New-SelectorCandidate -Width 0), (New-SelectorCandidate -Width 0) + ) + $details = @() + foreach ($variant in @('prefix','offscreen','exact-id','inactive')) { + $path = Join-Path $scratch ("selector-stages-$variant.jsonl") + Set-CapTracePath $path + $parameters = @{ Candidates = $candidates; ExpectedProcessId = 4242; Name = 'Alpha' } + if ($variant -ne 'inactive') { $parameters.AutomationIdPrefix = 'open-project-' } + if ($variant -in @('offscreen','inactive')) { $parameters.AllowOffscreen = $true } + if ($variant -ceq 'exact-id') { $parameters.AutomationId = 'OPEN-PROJECT-alpha' } + $null = Assert-CapRejects { Resolve-CapAutomationCandidate @parameters } 'Expected exactly one visible UIA element' + $data = Read-SelectorEvidence $path + Assert-CapTest ($data.stageCounts.total -eq 6 -and $data.stageCounts.processOwned -eq 6 -and + $data.stageCounts.nameMatch -eq 5 -and $data.stageCountsComplete) 'Full-set process/name stage counts changed.' + switch ($variant) { + 'prefix' { Assert-CapTest ($data.stageCounts.automationIdMatch -eq 5 -and + $data.stageCounts.prefixMatch -eq 4 -and $data.stageCounts.visible -eq 2 -and $data.stageCounts.bounds -eq 0) 'Prefix/visibility/bounds counts changed.' } + 'offscreen' { Assert-CapTest ($data.stageCounts.visible -eq 4 -and $data.stageCounts.bounds -eq 2) 'AllowOffscreen must count rule passage, not actual visibility.' } + 'exact-id' { Assert-CapTest ($data.stageCounts.automationIdMatch -eq 0 -and $data.stageCounts.prefixMatch -eq 0) 'Exact ID must remain case-sensitive before prefix.' } + 'inactive' { Assert-CapTest ($data.stageCounts.automationIdMatch -eq 5 -and + $data.stageCounts.prefixMatch -eq 5 -and $data.stageCounts.visible -eq 5 -and $data.stageCounts.bounds -eq 3) 'Inactive rules must propagate their predecessor count.' } + } + $details += $data + } + return @{ subcases = $details.Count; evidence = $details } + } + Invoke-CapTest 'selector refusal caps retain deterministic near matches typed PIDs and Unicode boundaries' { + $path = Join-Path $scratch 'selector-caps.jsonl' + Set-CapTracePath $path + $unicode = ('x' * 511) + [char]0xd83d + [char]0xde80 + 'z' + $candidates = @( + foreach ($i in 0..97) { + New-SelectorCandidate -CandidateProcessId (7000 + $i) -CandidateName $unicode -Id $unicode + } + New-SelectorCandidate -Offscreen $true + New-SelectorCandidate -Offscreen $true + ) + $null = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates $candidates -ExpectedProcessId 4242 -Name Alpha -AutomationIdPrefix 'open-project-' + } 'UIA subtree process 7000' + $data = Read-SelectorEvidence $path + Assert-CapTest ($data.stageCounts.total -eq 100 -and $data.stageCounts.processOwned -eq 2 -and + $data.stageCounts.prefixMatch -eq 2 -and $data.candidates.Count -eq 16 -and + $data.candidatesOmitted -eq 84 -and + ($data.candidates.sourceIndex -join ',') -ceq '98,99,0,1,2,3,4,5,6,7,8,9,10,11,12,13') 'Candidate cap/order must prefer deepest stages with stable source ties.' + $nameField = $data.candidates[2].Name + Assert-CapTest ($nameField.value.Length -eq 511 -and $nameField.originalLength -eq 514 -and + $nameField.truncated -and -not [char]::IsHighSurrogate($nameField.value[-1]) -and + $data.candidates[2].AutomationId.value.Length -eq 511) 'Scalar truncation split a Unicode surrogate pair.' + $null = [Text.UTF8Encoding]::new($false, $true).GetBytes($nameField.value) + Assert-CapTest ($data.observedProcessIdRawCount -eq 100 -and $data.observedProcessIdDistinctCount -eq 99 -and + $data.observedProcessIdSampleCount -eq 64 -and $data.observedProcessIds.Count -eq 64 -and + $data.observedProcessIdsTruncated -and -not $data.observedProcessIdsComplete -and + $data.observedProcessIds[0] -eq 7000 -and $data.observedProcessIds[-1] -eq 7063 -and + @($data.observedProcessIds | Where-Object { $_ -isnot [long] }).Count -eq 0) 'PID sample is not a bounded typed actual primitive sample with honest counts.' + return $data + } + Invoke-CapTest 'selector refusal distinguishes missing null and property read errors' { + $path = Join-Path $scratch 'selector-errors.jsonl' + Set-CapTracePath $path + $missing = New-SelectorCandidate -CandidateName Beta + $missing.PSObject.Properties.Remove('ControlType') + $nullValue = New-SelectorCandidate -CandidateName Beta + $nullValue.ControlType = $null + $errorValue = New-SelectorCandidate -CandidateName Beta + $errorValue.PSObject.Properties.Remove('ControlType') + $errorValue | Add-Member -MemberType ScriptProperty -Name ControlType -Value { throw 'controltype-observation-error' } + $boundsError = New-SelectorCandidate -CandidateName Beta + $boundsError.BoundingRectangle = [pscustomobject]@{ Left = 1; Top = 2; Height = 3 } + $null = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates @($missing,$nullValue,$errorValue,$boundsError) -ExpectedProcessId 4242 -Name Alpha + } 'found=0.' + $data = Read-SelectorEvidence $path + Assert-CapTest ($data.candidates[0].ControlType.status -ceq 'Missing' -and + $data.candidates[1].ControlType.status -ceq 'Null' -and + $data.candidates[2].ControlType.status -ceq 'ReadError' -and + $data.candidates[2].ControlType.error.value.Contains('controltype-observation-error') -and + $data.candidates[3].BoundingRectangle.status -ceq 'ReadError') 'Unavailable properties were fabricated or collapsed into empty scalar values.' + $badName = New-SelectorCandidate + $badName.PSObject.Properties.Remove('Name') + $path2 = Join-Path $scratch 'selector-stage-error.jsonl' + Set-CapTracePath $path2 + $null = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates @($badName) -ExpectedProcessId 4242 -Name Alpha + } "property 'Name'" + $incomplete = Read-SelectorEvidence $path2 + Assert-CapTest (-not $incomplete.stageCountsComplete -and $incomplete.stageReadErrors.nameMatch -eq 1 -and + $incomplete.candidates[0].Name.status -ceq 'Missing') 'Stage read error was reported as complete negative matching.' + return @{ evidence = $data; incomplete = $incomplete } + } + Invoke-CapTest 'selector success performs no new diagnostic write property read or serialization' { + $path = Join-Path $scratch 'selector-success.jsonl' + Set-CapTracePath $path + $candidate = New-SelectorCandidate + $candidate.PSObject.Properties.Remove('ControlType') + $candidate | Add-Member -MemberType ScriptProperty -Name ControlType -Value { throw 'Success must not read diagnostic-only ControlType.' } + $module = Get-Module UiCaptureDriver + $serializationCount = & $module { + param($Candidate) + $script:SelectorSerializationCount = 0 + function script:ConvertTo-Json { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$InputObject, [int]$Depth, [switch]$Compress) + process { + $script:SelectorSerializationCount++ + Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject $InputObject -Depth $Depth -Compress:$Compress + } + } + try { + $selected = Resolve-CapAutomationCandidate -Candidates @($Candidate) -ExpectedProcessId 4242 -Name Alpha ` + -AutomationIdPrefix 'open-project-' -RefusalContext @{ NotReadOnSuccess = $true } + if (-not [object]::ReferenceEquals($selected, $Candidate)) { throw 'Success selection changed.' } + return $script:SelectorSerializationCount + } finally { + Remove-Item Function:\ConvertTo-Json + $restored = Get-Command ConvertTo-Json + if ($restored.CommandType -ne [Management.Automation.CommandTypes]::Cmdlet -or + $restored.ModuleName -cne 'Microsoft.PowerShell.Utility') { throw 'Serialization spy command resolution was not restored.' } + Remove-Variable SelectorSerializationCount -Scope Script + } + } $candidate + $rows = @(Get-Content $path | ForEach-Object { ConvertFrom-Json $_ }) + Assert-CapTest ($serializationCount -eq 2 -and $rows.Count -eq 2 -and + @($rows | Where-Object command -CEQ 'CapSelectorRefusal').Count -eq 0 -and + -not $rows[0].parameters.PSObject.Properties['RefusalContext']) ` + 'Successful selector added diagnostic serialization/writes beyond its two original legacy traces.' + return @{ actualSerializations = $serializationCount; legacyTraceWrites = $rows.Count; diagnosticWrites = 0 } + } + Invoke-CapTest 'selector observer failure surfaces explicitly without replacing original ErrorRecord' { + $path = Join-Path $scratch 'selector-observer-error.jsonl' + Set-CapTracePath $path + $before = @(Get-CapTraceFailures).Count + $errorRecord = $null + try { + try { + $null = Resolve-CapAutomationCandidate -Candidates @((New-SelectorCandidate -CandidateName Beta)) ` + -ExpectedProcessId 4242 -Name Alpha -RefusalContext @{ + RootProcessId = $null; RootHandle = 'deliberately-invalid-observer-context' + Scope = 'Descendants'; ParameterSet = 'Window' } + } catch { $errorRecord = $_ } + $failures = @(Get-CapTraceFailures) + $invocation = if ($errorRecord) { $errorRecord.InvocationInfo } else { $null } + $invocationCommand = if ($null -ne $invocation) { $invocation.MyCommand } else { $null } + $commandNameProperty = if ($null -ne $invocationCommand) { $invocationCommand.PSObject.Properties['Name'] } else { $null } + Write-JournalContractJson (Join-Path $scratch 'selector-observer-error-fields.json') @{ + errorObserved = ($null -ne $errorRecord) + exceptionType = if ($errorRecord) { $errorRecord.Exception.GetType().FullName } else { $null } + message = if ($errorRecord) { $errorRecord.Exception.Message } else { $null } + fullyQualifiedErrorId = if ($errorRecord) { $errorRecord.FullyQualifiedErrorId } else { $null } + category = if ($errorRecord) { [string]$errorRecord.CategoryInfo.Category } else { $null } + invocationObserved = ($null -ne $invocation) + invocationCommandObserved = ($null -ne $invocationCommand) + invocationCommandType = if ($null -ne $invocationCommand) { $invocationCommand.GetType().FullName } else { $null } + invocationCommandNameObserved = ($null -ne $commandNameProperty) + invocationCommand = if ($null -ne $commandNameProperty) { $commandNameProperty.Value } else { $null } + invocationPosition = if ($null -ne $invocation) { $invocation.PositionMessage } else { $null } + scriptStackTrace = if ($errorRecord) { $errorRecord.ScriptStackTrace } else { $null } + traceFailuresBefore = $before; traceFailuresAfter = $failures.Count; traceFailures = $failures + } + Assert-CapTest ($null -ne $errorRecord -and $errorRecord.Exception -is [Management.Automation.RuntimeException] -and + $errorRecord.Exception.Message -ceq "Expected exactly one visible UIA element Name='Alpha' id='' prefix=''; found=0." -and + $errorRecord.FullyQualifiedErrorId -ceq $errorRecord.Exception.Message -and + $errorRecord.CategoryInfo.Category -eq [Management.Automation.ErrorCategory]::OperationStopped -and + $errorRecord.InvocationInfo.ScriptName -ceq $modulePath -and + $errorRecord.InvocationInfo.PositionMessage.Contains('throw "Expected exactly one visible UIA element') -and + $errorRecord.ScriptStackTrace.StartsWith('at Resolve-CapAutomationCandidate,', [StringComparison]::Ordinal) -and + $failures.Count -eq ($before + 1) -and $failures[-1].command -ceq 'CapSelectorRefusal') ` + 'Observer error masked the original selector ErrorRecord or failed silently.' + return @{ originalMessage = $errorRecord.Exception.Message; originalType = $errorRecord.Exception.GetType().FullName + surfacedObserverFailure = $failures[-1]; note = 'Deliberate invalid diagnostic context only; selector unchanged.' } + } finally { + & (Get-Module UiCaptureDriver) { param($Count) + while ($script:CapTraceFailures.Count -gt $Count) { $script:CapTraceFailures.RemoveAt($script:CapTraceFailures.Count - 1) } + } $before + } + } + function Invoke-SelectorWaitFixture { + param([string]$CaseName, [switch]$EventuallySucceeds, [switch]$NoSink, [switch]$BrokenObserver, + [switch]$StaleLastPoll) + $path = Join-Path $scratch ("selector-wait-$CaseName.jsonl") + Set-CapTracePath $path + $meter = [pscustomobject]@{ polls = 0; diagnosticReads = 0; diagnosticSerializations = 0; currentReads = 0 + inBudgetReads = 0; deadline = [DateTime]::UtcNow.AddSeconds(2); lastCandidate = $null + lastPoll = $null; failure = $null; selected = $null; snapshot = $null } + $module = Get-Module UiCaptureDriver + $failuresBefore = @(Get-CapTraceFailures).Count + & $module { + param($Meter, [bool]$NoSink) + $script:SelectorWaitMeter = $Meter + if ($NoSink) { $script:CapTracePath = $null } + function script:ConvertTo-Json { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$InputObject, [int]$Depth, [switch]$Compress) + process { + if ($InputObject -is [Collections.IDictionary] -and $InputObject.Contains('schema') -and + $InputObject['schema'] -ceq 'CapSelectorRefusal/1') { $script:SelectorWaitMeter.diagnosticSerializations++ } + Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject $InputObject -Depth $Depth -Compress:$Compress + } + } + } $meter $NoSink.IsPresent + try { + $ast = [Management.Automation.Language.Parser]::ParseFile($workerPath,[ref]$null,[ref]$null) + foreach ($functionName in @('Wait-CapVisibleElement','Assert-CapWorkerDeadline')) { + $definitions = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $functionName + },$true)) + Assert-CapTest ($definitions.Count -eq 1) 'Actual worker wait/deadline function is missing or duplicated.' + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + function Find-CapAutomationElement { + param([IntPtr]$RootHandle, $ProcessIdentity, [string]$Name, [string]$AutomationId, + [string]$AutomationIdPrefix, [switch]$AllowOffscreen, [ref]$RefusalSnapshot) + $meter.polls++ + $meter.lastPoll = [DateTime]::UtcNow + $candidate = New-SelectorCandidate -CandidateName ("Beta-" + $meter.polls) + if ($EventuallySucceeds -and $meter.polls -ge 2) { $candidate.Name = 'Alpha' } + $candidate.PSObject.Properties.Remove('ControlType') + $capturedMeter = $meter + $getter = { + $capturedMeter.diagnosticReads++ + if ([DateTime]::UtcNow -lt $capturedMeter.deadline) { $capturedMeter.inBudgetReads++ } + return 'ControlType.Button' + }.GetNewClosure() + $candidate | Add-Member -MemberType ScriptProperty -Name ControlType -Value $getter + $meter.lastCandidate = $candidate + $parameters = @{ Candidates = @($candidate); ExpectedProcessId = 4242; Name = $Name + AutomationId = $AutomationId; AutomationIdPrefix = $AutomationIdPrefix; AllowOffscreen = $AllowOffscreen + RefusalContext = @{ RootHandle = $RootHandle; RootProcessId = 4242; ProcessIdentity = $ProcessIdentity + Scope = 'Descendants'; ParameterSet = 'Window' } } + if ($null -ne $RefusalSnapshot) { $parameters.RefusalSnapshot = $RefusalSnapshot } + if ($BrokenObserver) { + $parameters.RefusalContext = @{ RootProcessId = $null; RootHandle = 'deliberately-invalid-observer-context' + Scope = 'Descendants'; ParameterSet = 'Window' } + } + try { return Resolve-CapAutomationCandidate @parameters } + catch { + if ($null -ne $RefusalSnapshot) { $meter.snapshot = $RefusalSnapshot.Value } + if ($StaleLastPoll) { + $staleGetter = { $capturedMeter.currentReads++; throw 'last-poll-element-vanished' }.GetNewClosure() + $candidate | Add-Member -MemberType ScriptProperty -Name Current -Value $staleGetter + } + throw + } + } + $leaseDeadlineUtc = $meter.deadline + try { + $meter.selected = Wait-CapVisibleElement -Window ([IntPtr]77) -ProcessIdentity ([pscustomobject]@{ Pid = 4242 }) ` + -Name Alpha -AutomationIdPrefix 'open-project-' + } catch { $meter.failure = $_ } + $rows = @() + if (Test-Path $path) { $rows = @(Get-Content $path | ForEach-Object { ConvertFrom-Json $_ -DateKind String }) } + $caught = $meter.failure + $fixtureFailures = @(Get-CapTraceFailures) + Write-JournalContractJson (Join-Path $scratch ("selector-wait-$CaseName-fields.json")) ([ordered]@{ + polls = $meter.polls; diagnosticReads = $meter.diagnosticReads; currentReads = $meter.currentReads + diagnosticSerializations = $meter.diagnosticSerializations; inBudgetReads = $meter.inBudgetReads + deadlineUtc = $meter.deadline.ToString('o') + lastQueryUtc = if ($null -ne $meter.lastPoll) { $meter.lastPoll.ToString('o') } else { $null } + snapshotObserved = ($null -ne $meter.snapshot) + emissionAttempted = if ($null -ne $meter.snapshot -and $meter.snapshot.Contains('EmissionAttempted')) { + $meter.snapshot.EmissionAttempted + } else { $null } + selectedObserved = ($null -ne $meter.selected); errorObserved = ($null -ne $caught) + exceptionType = if ($null -ne $caught) { $caught.Exception.GetType().FullName } else { $null } + message = if ($null -ne $caught) { $caught.Exception.Message } else { $null } + fullyQualifiedErrorId = if ($null -ne $caught) { $caught.FullyQualifiedErrorId } else { $null } + category = if ($null -ne $caught) { [string]$caught.CategoryInfo.Category } else { $null } + scriptStackTrace = if ($null -ne $caught) { $caught.ScriptStackTrace } else { $null } + traceRows = $rows.Count; traceFailuresBefore = $failuresBefore + traceFailuresAfter = $fixtureFailures.Count + traceFailures = @($fixtureFailures | Select-Object command,path,failure) + }) + return [pscustomobject]@{ meter = $meter; tracePath = $path; rows = $rows + traceFailures = $fixtureFailures; traceFailuresBefore = $failuresBefore } + } finally { + & $module { + param($Count) + Remove-Item Function:\ConvertTo-Json + if ((Get-Command ConvertTo-Json).CommandType -ne [Management.Automation.CommandTypes]::Cmdlet) { + throw 'Wait serialization spy was not removed.' + } + Remove-Variable SelectorWaitMeter -Scope Script + while ($script:CapTraceFailures.Count -gt $Count) { $script:CapTraceFailures.RemoveAt($script:CapTraceFailures.Count - 1) } + } $failuresBefore + Set-CapTracePath -Path (Join-Path $scratch 'test-calls.jsonl') + } + } + Invoke-CapTest 'actual worker wait eventual success performs zero refusal observation work' { + $run = Invoke-SelectorWaitFixture -CaseName success -EventuallySucceeds + Assert-CapTest ($null -eq $run.meter.failure -and $run.meter.polls -eq 2 -and + [object]::ReferenceEquals($run.meter.selected, $run.meter.lastCandidate)) 'Actual worker wait did not select its second synthetic query result.' + Assert-CapTest ($run.meter.diagnosticReads -eq 0 -and $run.meter.diagnosticSerializations -eq 0 -and + @($run.rows | Where-Object command -CEQ 'CapSelectorRefusal').Count -eq 0) ` + 'Eventually successful actual worker wait performed refusal diagnostic reads, serialization or writes inside polling.' + return @{ polls = $run.meter.polls; diagnosticReads = $run.meter.diagnosticReads + diagnosticSerializations = $run.meter.diagnosticSerializations; diagnosticWrites = 0 + mode = 'Actual worker wait/deadline and resolver; synthetic query leaf only; real 100ms cadence.' } + } + Invoke-CapTest 'actual worker terminal refusal observes only last poll references after deadline' { + $run = Invoke-SelectorWaitFixture -CaseName terminal + Assert-CapTest ($run.meter.polls -ge 2 -and $null -ne $run.meter.failure -and + $run.meter.failure.Exception.Message -ceq "UIA element 'Alpha' did not become available before the reserved deadline.") ` + 'Actual worker did not preserve its original terminal timeout refusal.' + $data = Read-SelectorEvidence $run.tracePath + $observedAt = [DateTime]::Parse($data.propertyReadStartedUtc).ToUniversalTime() + $polledAt = [DateTime]::Parse($data.terminalObservation.lastPollUtc).ToUniversalTime() + Assert-CapTest ($run.meter.diagnosticReads -eq 1 -and $run.meter.diagnosticSerializations -eq 1 -and + $run.meter.inBudgetReads -eq 0 -and $data.evidenceKind -ceq 'TERMINAL_LAST_POLL_SNAPSHOT' -and + $data.candidates[0].Name.value -ceq ("Beta-" + $run.meter.polls) -and + $observedAt -ge $run.meter.deadline -and $polledAt -le $run.meter.lastPoll -and + [DateTime]::Parse($data.terminalObservation.waitDeadlineUtc).ToUniversalTime() -eq $run.meter.deadline -and + $data.heldContext.rootHandle -eq 77 -and $data.heldContext.identity.Pid.value -eq 4242) ` + 'Terminal observation queried again, observed a prior poll, or read properties inside the wait budget.' + $stale = Invoke-SelectorWaitFixture -CaseName stale -StaleLastPoll + $staleData = Read-SelectorEvidence $stale.tracePath + Assert-CapTest ($stale.meter.polls -ge 2 -and $stale.meter.currentReads -eq 1 -and + $staleData.candidates[0].currentStatus -ceq 'ReadError' -and + $staleData.candidates[0].current.error.value.Contains('last-poll-element-vanished') -and + $staleData.candidates[0].Name.status -ceq 'ReadError' -and + -not $staleData.stageCountsComplete -and $staleData.observedProcessIds.Count -eq 0) ` + 'Stale Current getter was collapsed into null/missing or caused a fresh query/invented PID.' + return @{ polls = $run.meter.polls; evidence = $data; staleEvidence = $staleData + staleCurrentReads = $stale.meter.currentReads; queriesAfterLastPoll = 0 } + } + Invoke-CapTest 'selector special rectangle values retain successful reads and the original raw predicate' { + $path = Join-Path $scratch 'selector-special-bounds.jsonl' + Set-CapTracePath $path + $empty = New-SelectorCandidate + $empty.BoundingRectangle = [pscustomobject]@{ + Left = [double]::PositiveInfinity; Top = [double]::PositiveInfinity + Width = [double]::NegativeInfinity; Height = [double]::NegativeInfinity + } + $nan = New-SelectorCandidate + $nan.BoundingRectangle.Width = [double]::NaN + $infinity = New-SelectorCandidate + $infinity.BoundingRectangle.Width = [double]::PositiveInfinity + $candidates = @($empty,$nan,$nan,$infinity,$infinity) + $expected = 0 + foreach ($candidate in $candidates) { + $rect = $candidate.BoundingRectangle + if ($null -eq $rect -or $rect.Width -le 0 -or $rect.Height -le 0) { continue } + $expected++ + } + $null = Assert-CapRejects { + Resolve-CapAutomationCandidate -Candidates $candidates -ExpectedProcessId 4242 -Name Alpha + } "found=$expected." + $data = Read-SelectorEvidence $path + $byIndex = @($data.candidates | Sort-Object sourceIndex) + Assert-CapTest ($data.stageCounts.bounds -eq $expected -and $data.stageCountsComplete -and + $data.stageReadErrors.bounds -eq 0 -and + @($data.candidates | Where-Object { $_.BoundingRectangle.status -cne 'Value' -or + -not $_.BoundingRectangle.propertyReadSucceeded }).Count -eq 0 -and + $byIndex[0].BoundingRectangle.rectangleKind -ceq 'Empty' -and + $byIndex[0].BoundingRectangle.value.Width.numberKind -ceq 'NegativeInfinity' -and + $byIndex[0].BoundingRectangle.value.Left.numberKind -ceq 'PositiveInfinity' -and + $byIndex[1].BoundingRectangle.value.Width.numberKind -ceq 'NaN' -and + $byIndex[3].BoundingRectangle.value.Width.numberKind -ceq 'PositiveInfinity') ` + 'Successful special-number reads were mislabeled ReadError or changed the original raw bounds predicate.' + return @{ expectedByOriginalPredicate = $expected; evidence = $data + fixture = 'SYNTHETIC canonical Rect.Empty numeric shape; not a genuine WPF instance or live UIA.' } + } + Invoke-CapTest 'actual worker no sink terminal refusal performs zero observation or serialization work' { + $run = Invoke-SelectorWaitFixture -CaseName no-sink -NoSink + Assert-CapTest ($run.meter.polls -ge 2 -and $null -ne $run.meter.failure -and + $run.meter.diagnosticReads -eq 0 -and $run.meter.currentReads -eq 0 -and + $run.meter.diagnosticSerializations -eq 0 -and $null -eq $run.meter.snapshot -and + $run.rows.Count -eq 0 -and -not (Test-Path $run.tracePath)) ` + 'Missing trace sink still retained/prepared diagnostic context, read properties, serialized or wrote.' + return @{ polls = $run.meter.polls; diagnosticReads = 0; diagnosticSerializations = 0; writes = 0 } + } + Invoke-CapTest 'actual worker observer failure is attempted once per wait and preserves terminal refusal' { + $run = Invoke-SelectorWaitFixture -CaseName observer-error -BrokenObserver + Assert-CapTest ($run.meter.polls -ge 2 -and $null -ne $run.meter.failure -and + $run.meter.failure.Exception.Message -ceq "UIA element 'Alpha' did not become available before the reserved deadline." -and + $run.meter.failure.FullyQualifiedErrorId -ceq $run.meter.failure.Exception.Message -and + $run.meter.failure.CategoryInfo.Category -eq [Management.Automation.ErrorCategory]::OperationStopped -and + $run.meter.diagnosticReads -eq 1 -and $run.meter.inBudgetReads -eq 0 -and + $run.traceFailures.Count -eq ($run.traceFailuresBefore + 1) -and + $run.traceFailures[-1].command -ceq 'CapSelectorRefusal' -and $run.meter.snapshot.EmissionAttempted) ` + 'Observer failure ran inside polling, repeated, or masked the original worker terminal refusal.' + $before = @(Get-CapTraceFailures).Count + Write-CapTerminalSelectorRefusal -Snapshot $run.meter.snapshot -Refusal $run.meter.failure ` + -PollUtc $run.meter.lastPoll -DeadlineUtc $run.meter.deadline + Assert-CapTest ($run.meter.diagnosticReads -eq 1 -and @(Get-CapTraceFailures).Count -eq $before) ` + 'Already attempted wait snapshot emitted or failed a second time.' + return @{ polls = $run.meter.polls; originalMessage = $run.meter.failure.Exception.Message + observerAttempts = 1; explicitFailure = $run.traceFailures[-1] } + } + Set-CapTracePath -Path (Join-Path $scratch 'test-calls.jsonl') +} + +if ($SelectorRefusalContractOnly) { + Invoke-CapSelectorRefusalContracts + $passed = @($results | Where-Object passed -EQ $true).Count + $failed = @($results | Where-Object passed -EQ $false).Count + $report = [pscustomobject]@{ suite = 'UiCaptureSelectorRefusalContracts'; executed = $results.Count + passed = $passed; failed = $failed; cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore + mode = 'SYNTHETIC_CANDIDATES_ACTUAL_RESOLVER_AND_TRACE_NO_DESKTOP' } + $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed | ConvertTo-Json -Compress + if ($results.Count -ne 13 -or $passed -ne 13 -or $failed -ne 0) { exit 1 } + exit 0 +} + if ($PreflightReportContractOnly) { Invoke-CapTest 'actual rehearsal data satisfies the repaired report gate and refuses stale proof' { Invoke-CapPreflightReportContract } $passed = @($results | Where-Object passed -EQ $true).Count @@ -2284,6 +2780,7 @@ try { } else { $cimRaceTarget = $prebuilt.CimRace if (-not $LaunchContractOnly) { + Invoke-CapSelectorRefusalContracts Invoke-CapTest 'prebuilt fixture binder accepts exact artifacts and refuses missing or tampered bytes' { Invoke-CapPrebuiltFixtureContract } Invoke-CapTest 'journal phase timing records open copy total without changing budget or retry limits' { Invoke-CapJournalTimingContract } Invoke-CapFirstOpenGroups @@ -3287,6 +3784,6 @@ try { } [ordered]@{ suite = $suiteName; executed = $results.Count; passed = $passed; failed = $failed; artifacts = $scratch } | ConvertTo-Json -Compress -$expectedCount = if ($JournalContractOnly) { 10 } elseif ($LaunchContractOnly) { 2 } elseif ($RequireRealSeed) { 75 } else { 72 } +$expectedCount = if ($JournalContractOnly) { 10 } elseif ($LaunchContractOnly) { 2 } elseif ($RequireRealSeed) { 88 } else { 85 } if ($results.Count -ne $expectedCount -or $failed -ne 0 -or $passed -ne $results.Count) { exit 1 } exit 0 diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 10d47657..616a42c8 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -4729,7 +4729,7 @@ if ($LASTEXITCODE -ne 0) { throw "RED: offline real-launch capture child failed" $uiCaptureSummaries = @($uiCaptureOutput | Where-Object { $_ -is [string] -and $_.StartsWith('{"suite":"UiCaptureDriver",') }) if ($uiCaptureSummaries.Count -ne 1) { throw "RED: offline real-launch capture structured count missing or duplicated" } $uiCaptureSummary = ConvertFrom-Json $uiCaptureSummaries[0] -if ($uiCaptureSummary.executed -isnot [long] -or $uiCaptureSummary.executed -le 0 -or +if ($uiCaptureSummary.executed -isnot [long] -or $uiCaptureSummary.executed -ne 85 -or $uiCaptureSummary.passed -isnot [long] -or $uiCaptureSummary.passed -ne $uiCaptureSummary.executed -or $uiCaptureSummary.failed -isnot [long] -or $uiCaptureSummary.failed -ne 0) { throw "RED: offline real-launch capture child has no positive executed/passed contract count" diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index c87dcb53..b9b8b1f7 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -930,6 +930,266 @@ function Assert-CapUiaProcessIds { return $true } +function Write-CapSelectorRefusal { + param($Candidates, [bool]$CandidatesObserved, [Nullable[int]]$ExpectedProcessId, [string]$Name, + [string]$AutomationId, [string]$AutomationIdPrefix, [bool]$AllowOffscreen, + [Management.Automation.ErrorRecord]$Refusal, $Context, $TerminalObservation) + if (-not $script:CapTracePath) { return } + try { + $propertyReadStartedUtc = [DateTime]::UtcNow.ToString('o') + function Limit-RefusalString { + param([string]$Value, [int]$Limit = 512) + $length = $Value.Length + $take = [Math]::Min($length, $Limit) + if ($take -gt 0 -and $take -lt $length -and [char]::IsHighSurrogate($Value[$take - 1])) { $take-- } + $text = [Text.StringBuilder]::new($take) + $replaced = $false + for ($i = 0; $i -lt $take; $i++) { + $character = $Value[$i] + if ([char]::IsHighSurrogate($character) -and $i + 1 -lt $take -and [char]::IsLowSurrogate($Value[$i + 1])) { + $null = $text.Append($character).Append($Value[++$i]) + } elseif ([char]::IsSurrogate($character)) { + $null = $text.Append([char]0xfffd) + $replaced = $true + } else { $null = $text.Append($character) } + } + return [ordered]@{ value = $text.ToString(); originalLength = $length; truncated = ($take -lt $length) + invalidSurrogatesReplaced = $replaced } + } + function Read-RefusalField { + param($Object, [string]$Property, [string]$Kind = 'String', [int]$Limit = 512) + $record = [ordered]@{ status = 'Missing'; value = $null; propertyReadSucceeded = $null } + $raw = $null + try { + if ($null -ne $Object -and $Object.PSObject.Properties[$Property]) { + $raw = $Object.PSObject.Properties[$Property].get_Value() + $record.propertyReadSucceeded = $true + if ($null -eq $raw) { $record.status = 'Null' } + else { + $record.status = 'Value' + switch ($Kind) { + 'Current' { } + 'Int' { + if (($raw -isnot [int] -and $raw -isnot [long]) -or + $raw -lt [int]::MinValue -or $raw -gt [int]::MaxValue) { throw 'Not an Int32 primitive.' } + $record.value = [int]$raw + } + 'Long' { + if ($raw -isnot [int] -and $raw -isnot [long]) { throw 'Not an Int64 primitive.' } + $record.value = [long]$raw + } + 'Bool' { + if ($raw -isnot [bool]) { throw 'Not a Boolean primitive.' } + $record.value = $raw + } + 'Rectangle' { + $rectangle = [ordered]@{} + $numbers = [ordered]@{} + foreach ($part in @('Left','Top','Width','Height')) { + $partProperty = $raw.PSObject.Properties[$part] + if ($null -eq $partProperty) { throw "Rectangle property '$part' is missing." } + $number = [double]$partProperty.get_Value() + $numbers[$part] = $number + $rectangle[$part] = if ([double]::IsNaN($number)) { + [ordered]@{ numberKind = 'NaN' } + } elseif ([double]::IsPositiveInfinity($number)) { + [ordered]@{ numberKind = 'PositiveInfinity' } + } elseif ([double]::IsNegativeInfinity($number)) { + [ordered]@{ numberKind = 'NegativeInfinity' } + } else { $number } + } + $record.value = $rectangle + $record.rectangleKind = if ([double]::IsPositiveInfinity($numbers.Left) -and + [double]::IsPositiveInfinity($numbers.Top) -and + [double]::IsNegativeInfinity($numbers.Width) -and + [double]::IsNegativeInfinity($numbers.Height)) { 'Empty' } else { 'Rectangle' } + } + default { + $text = if ($Kind -ceq 'ControlType' -and $raw -isnot [string]) { + [string]$raw.ProgrammaticName + } else { [string]$raw } + $bounded = Limit-RefusalString $text $Limit + $record.value = $bounded.value + $record.originalLength = $bounded.originalLength + $record.truncated = $bounded.truncated + $record.invalidSurrogatesReplaced = $bounded.invalidSurrogatesReplaced + } + } + } + } + } catch { + $record.status = 'ReadError' + $record.value = $null + $record.propertyReadSucceeded = $false + $record.error = Limit-RefusalString $_.Exception.Message 256 + $record.errorType = (Limit-RefusalString $_.Exception.GetType().FullName 128).value + } + return [pscustomobject]@{ Raw = $raw; Record = $record } + } + $counts = [ordered]@{ total = $null; processOwned = $null; nameMatch = $null; automationIdMatch = $null + prefixMatch = $null; visible = $null; bounds = $null } + $stageErrors = [ordered]@{ processOwned = 0; nameMatch = 0; automationIdMatch = 0; prefixMatch = 0; visible = 0; bounds = 0 } + $sample = [Collections.Generic.List[object]]::new() + $pids = [Collections.Generic.HashSet[int]]::new() + $pidSample = [Collections.Generic.List[int]]::new() + $rawPidCount = 0 + $pidUnavailableCount = 0 + if ($CandidatesObserved) { + foreach ($key in @($counts.Keys)) { $counts[$key] = 0 } + $index = 0 + foreach ($candidate in $Candidates) { + $counts.total++ + $current = $candidate + $currentStatus = 'Value' + $currentRecord = [ordered]@{ status = 'DirectCandidate'; propertyReadSucceeded = $null } + if ($null -eq $candidate) { $currentStatus = 'Null' } + elseif ($candidate.PSObject.Properties['Current']) { + $reading = Read-RefusalField $candidate 'Current' 'Current' + $current = $reading.Raw + $currentRecord = $reading.Record + $currentStatus = $reading.Record.status + } + $fields = [ordered]@{} + foreach ($spec in @( + @('Name','String',512), @('AutomationId','String',512), @('ProcessId','Int',512), + @('IsOffscreen','Bool',512), @('BoundingRectangle','Rectangle',512), @('ControlType','ControlType',128) + )) { + $field = Read-RefusalField $current $spec[0] $spec[1] $spec[2] + if ($currentStatus -ceq 'ReadError') { + $field.Record.status = 'ReadError' + $field.Record.propertyReadSucceeded = $false + $field.Record.error = $currentRecord.error + $field.Record.errorType = $currentRecord.errorType + } + $fields[$spec[0]] = $field + } + $pidField = $fields.ProcessId + if ($pidField.Record.status -ceq 'Value') { + $rawPidCount++ + if ($pids.Add([int]$pidField.Raw) -and $pidSample.Count -lt 64) { $pidSample.Add([int]$pidField.Raw) } + } else { $pidUnavailableCount++ } + $eligible = $true + $rank = 0 + foreach ($stage in @('processOwned','nameMatch','automationIdMatch','prefixMatch','visible','bounds')) { + if (-not $eligible) { break } + $fieldName = switch ($stage) { + 'processOwned' { 'ProcessId' } + 'nameMatch' { 'Name' } + 'automationIdMatch' { 'AutomationId' } + 'prefixMatch' { 'AutomationId' } + 'visible' { 'IsOffscreen' } + 'bounds' { 'BoundingRectangle' } + } + $inactive = ($stage -ceq 'automationIdMatch' -and -not $AutomationId) -or + ($stage -ceq 'prefixMatch' -and -not $AutomationIdPrefix) -or + ($stage -ceq 'visible' -and $AllowOffscreen) + $field = $fields[$fieldName] + if (-not $inactive -and $field.Record.status -in @('Missing','ReadError')) { + $stageErrors[$stage]++ + $eligible = $false + break + } + $eligible = $inactive -or $(switch ($stage) { + 'processOwned' { $field.Record.status -ceq 'Value' -and [int]$field.Raw -eq $ExpectedProcessId -and [int]$field.Raw -gt 0 } + 'nameMatch' { [string]$field.Raw -ceq $Name } + 'automationIdMatch' { [string]$field.Raw -ceq $AutomationId } + 'prefixMatch' { ([string]$field.Raw).StartsWith($AutomationIdPrefix, [StringComparison]::Ordinal) } + 'visible' { -not [bool]$field.Raw } + 'bounds' { -not ($null -eq $field.Raw -or $field.Raw.Width -le 0 -or $field.Raw.Height -le 0) } + }) + if ($eligible) { $counts[$stage]++; $rank++ } + } + $row = [ordered]@{ sourceIndex = $index; passedStages = $rank; currentStatus = $currentStatus + current = $currentRecord } + foreach ($key in $fields.Keys) { $row[$key] = $fields[$key].Record } + $sample.Add([pscustomobject]$row) + if ($sample.Count -gt 16) { + $worst = 0 + for ($i = 1; $i -lt $sample.Count; $i++) { + if ($sample[$i].passedStages -lt $sample[$worst].passedStages -or + ($sample[$i].passedStages -eq $sample[$worst].passedStages -and + $sample[$i].sourceIndex -gt $sample[$worst].sourceIndex)) { $worst = $i } + } + $sample.RemoveAt($worst) + } + $index++ + } + } + $held = [ordered]@{ status = 'NotObserved' } + if ($null -ne $Context) { + $held = [ordered]@{ status = 'Observed'; rootHandle = $null; rootProcessId = $Context.RootProcessId + rootProcessIdStatus = if ($null -eq $Context.RootProcessId) { 'NotObserved' } else { 'Observed' } + parameterSet = [string]$Context.ParameterSet; scope = [string]$Context.Scope + identity = [ordered]@{} } + if ($null -ne $Context.RootHandle) { $held.rootHandle = $Context.RootHandle.ToInt64() } + foreach ($property in @('Pid','CreationFileTimeUtc','ExpectedImagePath','Role')) { + $kind = if ($property -ceq 'Pid') { 'Int' } elseif ($property -ceq 'CreationFileTimeUtc') { 'Long' } else { 'String' } + $held.identity[$property] = (Read-RefusalField $Context.ProcessIdentity $property $kind).Record + } + } + $complete = $CandidatesObserved + foreach ($key in $stageErrors.Keys) { if ($stageErrors[$key] -gt 0) { $complete = $false } } + $diagnostic = [ordered]@{ + schema = 'CapSelectorRefusal/1' + evidenceKind = if ($null -ne $TerminalObservation) { 'TERMINAL_LAST_POLL_SNAPSHOT' } else { 'DIRECT_SELECTOR_REFUSAL' } + terminalObservation = $TerminalObservation + propertyReadStartedUtc = $propertyReadStartedUtc + selector = [ordered]@{ Name = Limit-RefusalString $Name; AutomationId = Limit-RefusalString $AutomationId + AutomationIdPrefix = Limit-RefusalString $AutomationIdPrefix; allowOffscreen = $AllowOffscreen } + expectedProcessId = $ExpectedProcessId; heldContext = $held + refusal = [ordered]@{ type = $Refusal.Exception.GetType().FullName + message = (Limit-RefusalString $Refusal.Exception.Message 1024).value + messageLength = $Refusal.Exception.Message.Length; messageTruncated = ($Refusal.Exception.Message.Length -gt 1024) } + candidatesObserved = $CandidatesObserved; stageCounts = $counts; stageReadErrors = $stageErrors + stageCountsComplete = $complete + samplePolicy = 'deepest-passed-stage-then-source-index'; candidateLimit = 16 + candidateSampleCount = $sample.Count + candidatesOmitted = if ($CandidatesObserved) { $counts.total - $sample.Count } else { $null } + candidates = @($sample | Sort-Object @{ Expression = 'passedStages'; Descending = $true }, sourceIndex) + observedProcessIds = @($pidSample.ToArray()); observedProcessIdLimit = 64 + observedProcessIdRawCount = $rawPidCount; observedProcessIdDistinctCount = $pids.Count + observedProcessIdSampleCount = $pidSample.Count; observedProcessIdsTruncated = ($pids.Count -gt 64) + observedProcessIdsComplete = ($CandidatesObserved -and $pidUnavailableCount -eq 0 -and $pids.Count -le 64) + observedProcessIdUnavailableCount = $pidUnavailableCount + } + $json = ConvertTo-Json -InputObject $diagnostic -Depth 16 -Compress + Write-CapTrace -Command 'CapSelectorRefusal' -Parameters @{ diagnosticJson = $json } + } catch { + if (-not @($script:CapTraceFailures | Where-Object command -CEQ 'CapSelectorRefusal').Count) { + $script:CapTraceFailures.Add([pscustomobject]@{ + command = 'CapSelectorRefusal'; path = $script:CapTracePath; failure = $_.Exception.ToString() + }) + [Console]::Error.WriteLine("CAP_TRACE_FAILURE command=CapSelectorRefusal: $($_.Exception.Message)") + } + } +} + +function Write-CapTerminalSelectorRefusal { + [CmdletBinding()] + param($Snapshot, [Management.Automation.ErrorRecord]$Refusal, [Nullable[DateTime]]$PollUtc, + [DateTime]$DeadlineUtc, [IntPtr]$Window, $ProcessIdentity, [string]$Name, + [string]$AutomationId, [string]$AutomationIdPrefix, [switch]$AllowOffscreen) + if (-not $script:CapTracePath) { return } + if ($null -ne $Snapshot) { + if ($Snapshot.EmissionAttempted) { return } + $Snapshot.EmissionAttempted = $true + } else { + $Snapshot = @{ Candidates = $null; CandidatesObserved = $false + ExpectedProcessId = $null; Name = $Name; AutomationId = $AutomationId + AutomationIdPrefix = $AutomationIdPrefix; AllowOffscreen = $AllowOffscreen.IsPresent + Context = @{ RootHandle = $Window; RootProcessId = $null; ProcessIdentity = $ProcessIdentity + Scope = 'Descendants'; ParameterSet = 'Window' } } + } + Write-CapSelectorRefusal -Candidates $Snapshot.Candidates -CandidatesObserved $Snapshot.CandidatesObserved ` + -ExpectedProcessId $Snapshot.ExpectedProcessId -Name $Snapshot.Name -AutomationId $Snapshot.AutomationId ` + -AutomationIdPrefix $Snapshot.AutomationIdPrefix -AllowOffscreen $Snapshot.AllowOffscreen ` + -Refusal $Refusal -Context $Snapshot.Context -TerminalObservation ([ordered]@{ + lastPollUtc = if ($null -ne $PollUtc) { $PollUtc.ToString('o') } else { $null } + waitDeadlineUtc = $DeadlineUtc.ToString('o'); failureObservedUtc = [DateTime]::UtcNow.ToString('o') + meaning = 'Last poll references; property values are read after terminal failure, not frozen at poll time.' + }) +} + function Resolve-CapAutomationCandidate { [CmdletBinding()] param( @@ -938,11 +1198,17 @@ function Resolve-CapAutomationCandidate { [Parameter(Mandatory)][string]$Name, [string]$AutomationId, [string]$AutomationIdPrefix, - [switch]$AllowOffscreen + [switch]$AllowOffscreen, + [System.Collections.IDictionary]$RefusalContext, + [ref]$RefusalSnapshot ) - Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters - + $traceParameters = [ordered]@{} + foreach ($key in $PSBoundParameters.Keys) { + if ($key -cnotin @('RefusalContext','RefusalSnapshot')) { $traceParameters[$key] = $PSBoundParameters[$key] } + } + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $traceParameters + try { $found = [Collections.Generic.List[object]]::new() foreach ($candidate in $Candidates) { if ($null -eq $candidate) { throw 'UIA resolver returned a null candidate.' } @@ -960,6 +1226,20 @@ function Resolve-CapAutomationCandidate { throw "Expected exactly one visible UIA element Name='$Name' id='$AutomationId' prefix='$AutomationIdPrefix'; found=$($found.Count)." } return $found[0] + } catch { + if ($script:CapTracePath) { + if ($null -ne $RefusalSnapshot) { + $RefusalSnapshot.Value = @{ Candidates = $Candidates; CandidatesObserved = $true; ExpectedProcessId = $ExpectedProcessId + Name = $Name; AutomationId = $AutomationId; AutomationIdPrefix = $AutomationIdPrefix + AllowOffscreen = $AllowOffscreen.IsPresent; Context = $RefusalContext; EmissionAttempted = $false } + } else { + Write-CapSelectorRefusal -Candidates $Candidates -CandidatesObserved $true -ExpectedProcessId $ExpectedProcessId ` + -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix ` + -AllowOffscreen $AllowOffscreen.IsPresent -Refusal $_ -Context $RefusalContext + } + } + throw + } } function Find-CapAutomationElement { @@ -973,11 +1253,19 @@ function Find-CapAutomationElement { [string]$AutomationId, [string]$AutomationIdPrefix, [System.Windows.Automation.TreeScope]$Scope = [System.Windows.Automation.TreeScope]::Descendants, - [switch]$AllowOffscreen + [switch]$AllowOffscreen, + [ref]$RefusalSnapshot ) - Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $traceParameters = [ordered]@{} + foreach ($key in $PSBoundParameters.Keys) { + if ($key -cne 'RefusalSnapshot') { $traceParameters[$key] = $PSBoundParameters[$key] } + } + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $traceParameters + $resolving = $false + $rootProcessId = $null + try { $rootElement = if ($PSCmdlet.ParameterSetName -eq 'Window') { [System.Windows.Automation.AutomationElement]::FromHandle($RootHandle) } else { @@ -997,8 +1285,30 @@ function Find-CapAutomationElement { } $null = Assert-CapUiaProcessIds -ExpectedProcessId $ExpectedProcessId -ObservedProcessIds @($rootProcessId) $elements = $rootElement.FindAll($Scope, [System.Windows.Automation.Condition]::TrueCondition) + $resolving = $true return Resolve-CapAutomationCandidate -Candidates @($elements) -ExpectedProcessId $ExpectedProcessId ` - -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix -AllowOffscreen:$AllowOffscreen + -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix -AllowOffscreen:$AllowOffscreen ` + -RefusalSnapshot $RefusalSnapshot -RefusalContext $(if ($script:CapTracePath) { + @{ RootHandle = if ($PSCmdlet.ParameterSetName -eq 'Window') { $RootHandle } else { $null } + ProcessIdentity = $ProcessIdentity; RootProcessId = $rootProcessId; Scope = $Scope; ParameterSet = $PSCmdlet.ParameterSetName } + } else { $null }) + } catch { + if (-not $resolving -and $script:CapTracePath) { + $context = @{ + RootHandle = if ($PSCmdlet.ParameterSetName -eq 'Window') { $RootHandle } else { $null } + ProcessIdentity = $ProcessIdentity; RootProcessId = $rootProcessId; Scope = $Scope; ParameterSet = $PSCmdlet.ParameterSetName } + if ($null -ne $RefusalSnapshot) { + $RefusalSnapshot.Value = @{ Candidates = $null; CandidatesObserved = $false; ExpectedProcessId = $ExpectedProcessId + Name = $Name; AutomationId = $AutomationId; AutomationIdPrefix = $AutomationIdPrefix + AllowOffscreen = $AllowOffscreen.IsPresent; Context = $context; EmissionAttempted = $false } + } else { + Write-CapSelectorRefusal -Candidates $null -CandidatesObserved $false -ExpectedProcessId $ExpectedProcessId ` + -Name $Name -AutomationId $AutomationId -AutomationIdPrefix $AutomationIdPrefix ` + -AllowOffscreen $AllowOffscreen.IsPresent -Refusal $_ -Context $context + } + } + throw + } } function Get-CapDirectChildren { @@ -3353,6 +3663,7 @@ Export-ModuleMember -Function @( 'Assert-CapNoWorkerTransientArtifacts', 'Set-CapTracePath', 'Write-CapTrace', + 'Write-CapTerminalSelectorRefusal', 'Get-CapTraceFailures', 'Assert-CapArtifactPath', 'Assert-CapZmxLeasePath', diff --git a/windows-tests/ui-scenarios/README.md b/windows-tests/ui-scenarios/README.md index 53eb5043..1a54ac6a 100644 --- a/windows-tests/ui-scenarios/README.md +++ b/windows-tests/ui-scenarios/README.md @@ -386,13 +386,13 @@ Test definitions retain the original 59 cases (the native export contract is updated to 26), adding three contained harmless normal/nonzero/timeout cases, one STD source-ordering check, and N1's full numbered-final-proof synthetic output-descendant accept/refusal fixture, plus one negative umbrella safety-net -release assertion, lease-path and receipt-IO groups, prebuilt binding, journal-phase timing, first-Open and buffered-observation contracts: 72 expected cases. N1 is not real +release assertion, lease-path and receipt-IO groups, prebuilt binding, journal-phase timing, first-Open, buffered-observation and selector-refusal contracts: 85 expected cases. N1 is not real seed evidence. `-RequireRealSeed` additionally defines three actual-command cases: two real zmx phases before a normal, nonzero, or timed-out harmless -target, for 75 expected cases. It requires explicit `-RealSeedZmxPath` and +target, for 88 expected cases. It requires explicit `-RealSeedZmxPath` and `-RealSeedZmxSha256` inputs admitted against the pinned provider; there is no PATH or stale-candidate fallback. Without those cases, the repaired report -gate refuses live admission even if the 72 offline cases pass. +gate refuses live admission even if the 85 offline cases pass. None of these new native cases ran during M1. Parse/compile-only evidence does not establish job nesting, gate timing, output transport, native STD @@ -475,7 +475,7 @@ test override. Retry trace observations assert actual sharing/parse retries. release case for discriminating RED/GREEN evidence. These five contracts are registered as one full-suite case within the current -72/75 prospective counts above. Focused reports are named +85/88 prospective counts above. Focused reports are named `UiCaptureReceiptContracts` and cannot stand in for a complete capture preflight. A file-IO GREEN is not native lifecycle or job-zero GREEN. The strict outer job-zero gate remains unchanged; there is no vctip exemption. @@ -629,8 +629,9 @@ pwsh -NoProfile -File Tools\windows\Tests\UiCaptureDriver.Tests.ps1 ` The two prior #845 setup groups gave the historical 69 offline / 72 real-seed inventory. The two #851 first-Open groups brought that inventory to 71 offline / 74 -real-seed cases. The #852 buffered-observation group brings current full-suite -expectations to 72 offline / 75 real-seed cases. Historical run counts are not +real-seed cases. The #852 buffered-observation group brought that inventory +to 72 offline / 75 real-seed cases. The thirteen #848 selector-refusal groups +bring current full-suite expectations to 85 offline / 88 real-seed cases. Historical run counts are not relabelled by these additions. A focused setup-contract pass is not a complete capture, native job-zero or parity pass. @@ -641,3 +642,91 @@ does not add a suite job; precompilation there preserves the same input contract, not a claim of vctip containment. An isolated execution of its capture-caller slice is not the full ValidationRunner or the separate real-zmx qualification run. + +## Selector refusal diagnostics (issue 848) + +An already-refusing direct selector writes one `CapSelectorRefusal` record through the +existing trace sink. Its `parameters.diagnosticJson` string is bounded JSON with +schema `CapSelectorRefusal/1`; consumers parse that string as JSON rather than +treating legacy CLR type names as candidate or PID evidence. The original +exception type, message and ErrorRecord are rethrown unchanged. An observer +failure is surfaced as `CAP_TRACE_FAILURE` and by `Get-CapTraceFailures`, not +substituted for the selector refusal. Successful selectors add no diagnostic +property reads, serialization or writes. + +The worker's actual `Wait-CapVisibleElement` is different from a direct selector: +its zero-match refusals are intermediate polls, not terminal wait failures. +Polling retains only the last already-enumerated candidate references and cheap +call context. It does no diagnostic property reads, projection, serialization +or writes. Only after the wait definitively fails does it attempt one +`TERMINAL_LAST_POLL_SNAPSHOT` record, outside the wait budget, without another +tree/window query. The original wait refusal remains primary. This adds no +timeout allowance or retry. With no trace sink, neither the resolver nor the +terminal observer prepares a snapshot/context or performs diagnostic work. +An attempted snapshot cannot emit or report an observer failure twice. + +`terminalObservation.lastPollUtc` and `waitDeadlineUtc` bind the retained +references to the wait; `failureObservedUtc` and `propertyReadStartedUtc` mark +the later observation. The properties are deliberately read **after failure**, +not frozen values from the last poll. They may have changed or vanished. +An unavailable `Current` getter is retained as a typed read error and is +propagated to the unavailable candidate fields, never treated as an empty +element or a reason to query again. + +`stageCounts` scans the full existing candidate collection, cumulatively in the +original order: process-owned, exact case-sensitive Name, optional exact ID, +optional ordinal prefix, visibility rule, positive bounds. Inactive optional +stages propagate the previous count. With `AllowOffscreen`, `visible` counts +passage through the visibility **rule**, not actually visible elements. +`stageReadErrors` and `stageCountsComplete` distinguish unavailable decision +properties from observed negative matches. A query not reached is +`candidatesObserved=false`, with null counts, not an invented empty result. +Held window/process-generation context comes only from existing call data; +unavailable context/root PID is labelled `NotObserved`. + +At most 16 candidates are retained, ranked by deepest passed cumulative stage +then original source index. Total/sample/omitted counts and the sample policy +are explicit. Each Name, AutomationId, ProcessId, IsOffscreen, BoundingRectangle +and ControlType field distinguishes `Value`, `Null`, `Missing` and `ReadError`. +Successfully read `Rect.Empty` remains `Value` with `rectangleKind=Empty`. +Finite rectangle coordinates are JSON numbers; special coordinates are typed +`numberKind` markers (`NaN`, `PositiveInfinity`, `NegativeInfinity`). +`propertyReadSucceeded` distinguishes those real values from failed reads. +The bounds stage uses the original raw null/Width-less-than-or-equal-zero/ +Height-less-than-or-equal-zero rejection predicate literally, including its +special-number semantics; it does not replace it with a positive comparison. +ControlType is a scalar programmatic-name string, not a serialized UIA object. +Strings are capped at 512 UTF-16 units (ControlType 128, errors 256, recorded +refusal message 1024), with original-length/truncation metadata. Truncation +never splits a surrogate pair; isolated surrogates are replaced and disclosed. +Only the diagnostic copy of a message is clipped. + +`observedProcessIds` is the first 64 **distinct actually read primitive PIDs** +in source order, not a full lossless observed array. Raw observation count, +distinct count, sample count, unavailable count, truncation and completeness +are recorded separately. The expected PID is never substituted as an +observation. Diagnostics do not query a fresh tree/window, retarget, adopt a +foreign candidate, retry, or change selector/deadline/ownership decisions. + +The focused thirteen-case group uses synthetic candidates with the actual +resolver and retained trace sink, plus the actual worker wait/deadline +functions extracted by AST and a synthetic query leaf. It uses the real +100 ms polling cadence and compiles/launches no target: + +```powershell +pwsh -NoProfile -File Tools\windows\Tests\UiCaptureDriver.Tests.ps1 ` + -SelectorRefusalContractOnly -ScratchRoot C:\owned\fresh-selector-contracts +``` + +The empty-bounds fixture is a **synthetic canonical Rect.Empty numeric shape**, +not an instantiated WPF rectangle or live UIA evidence. Structural +classification uses the four already-read primitive coordinates, without +adding a WindowsBase dependency or changing assembly loading. + +All thirteen cases are also registered in the normal suite: exactly 85 ordinary +or 88 real-seed cases, preserving all 75 real-seed case names from the merged +journal base. The four timing +subcases and journal success path are unchanged. This diagnostic evidence is +not live UIA, graph-delivery, rendering, input, B-scene or parity evidence; +the live refusal's root cause remains unclassified until separately admitted +retained runtime evidence is available.