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.