From f306161a9ae8018cf1e4efdd75eb8a5bcfbea7f8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 05:06:53 -0700 Subject: [PATCH] Fix dynamic project card UIA bounds gate Normalize and reacquire the project canvas before freezing exact card geometry at high DPI, while preserving strict identity, ordering, ownership, overlap, and viewport checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 47 +++- Tools/windows/uia-live-gate.ps1 | 208 +++++++++++++++++- 2 files changed, 241 insertions(+), 14 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 5cac5272..0db97ec4 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -11,7 +11,8 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate ($node.Name -like "*-MultiProject*" -or $node.Name -cin @( "ConvertTo-SketchCanonicalJson", "Test-RetryableUiaError", - "Test-UpdateBannerClickTarget" + "Test-UpdateBannerClickTarget", + "Get-DynamicProjectCardSnapshotDecision" )) }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } } @@ -1907,7 +1908,42 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate Assert-MultiCase (Test-MultiProjectObservedSurface "project" $true ([ordered]@{ loopBars = @(); toolbars = @(); foreignWorkspaceFragmentCount = 0 }) $workspaceOwner 4242) "project-only marker shape rejected" - return "Two ordinary project rows, one exact project-card projection" + $expectedDynamic = @( + [ordered]@{ automationId = "canvas-card-1983941480823304696"; name = "UIA loop A" }, + [ordered]@{ automationId = "canvas-card-1983940381311676485"; name = "UIA loop B" } + ) + $dynamic = @( + [ordered]@{ automationId = $expectedDynamic[0].automationId; name = $expectedDynamic[0].name + processId = 4242; bounds = @(496,236,936,408) }, + [ordered]@{ automationId = $expectedDynamic[1].automationId; name = $expectedDynamic[1].name + processId = 4242; bounds = @(976,236,1416,408) } + ) + $graphBounds = @(440,68,2400,1300) + Assert-MultiCase ((Get-DynamicProjectCardSnapshotDecision "project" $dynamic $expectedDynamic 4242 $graphBounds).ready) ` + "exact DPI192 project-card snapshot was rejected" + $empty = Copy-MultiProjectValue $dynamic + $empty[1].bounds = @(0,0,0,0) + $emptyDecision = Get-DynamicProjectCardSnapshotDecision "project" $empty $expectedDynamic 4242 $graphBounds + Assert-MultiCase (-not $emptyDecision.ready -and $emptyDecision.failure -ceq "bounds-empty" -and + $emptyDecision.cards[1].automationId -ceq $expectedDynamic[1].automationId) ` + "identity-present DPI192 empty bounds did not fail exactly" + foreach ($mutation in @("missing", "foreign", "duplicate", "order", "wrong-surface", "outside-viewport", "overlap")) { + $candidate = Copy-MultiProjectValue $dynamic + $surface = "project" + switch ($mutation) { + "missing" { $candidate = @($candidate[0]) } + "foreign" { $candidate[1].processId = 7777 } + "duplicate" { $candidate[1].automationId = $candidate[0].automationId } + "order" { $candidate = @($candidate[1], $candidate[0]) } + "wrong-surface" { $surface = "overview" } + "outside-viewport" { $candidate[1].bounds = @(2600,236,3040,408) } + "overlap" { $candidate[1].bounds = @(700,236,1140,408) } + } + Assert-MultiCase (-not (Get-DynamicProjectCardSnapshotDecision ` + $surface $candidate $expectedDynamic 4242 $graphBounds).ready) ` + "dynamic project-card snapshot accepted $mutation" + } + return "Exact DPI192 frozen card snapshot; identity-present empty bounds and missing/foreign/duplicate/order/surface/viewport/overlap negatives" } foreach ($mutation in @("extra-card", "extra-project", "duplicate-card", "duplicate-project", "foreign-card-pid", "foreign-project-pid", "foreign-card-count", "foreign-project-count")) { @@ -4211,6 +4247,13 @@ Start-Sleep -Seconds 60 "projectBounds", "sidebarBounds", "source-derived, not a UIA caption")) { if (-not $uiaLiveGateSource.Contains($required)) { throw "RED: multi-project live evidence lacks $required" } } + if (-not $uiaLiveGateSource.Contains("function Get-DynamicProjectCardSnapshotDecision") -or + -not $uiaLiveGateSource.Contains('Get-DynamicProjectCardSnapshotDecision "project"') -or + -not $uiaLiveGateSource.Contains("function Get-DynamicProjectCardCapture") -or + -not $uiaLiveGateSource.Contains("UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=") -or + -not $uiaLiveGateSource.Contains('"fit-canvas"')) { + throw "RED: dynamic project cards are not frozen after bounded Fit Canvas normalization and reacquisition" + } if (-not $uiaLiveGateSource.Contains('$matches = Test-MultiProjectObservedSurface $surface $matches $workspaceProjection $selectedOwner $multiProcess.Id') -or $uiaLiveGateSource.Contains('if ($null -ne $loopBar) { $matches = $matches -and')) { throw "RED: workspace observation still accepts a project-only projection without mandatory owned loop-bar and toolbar proof" diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index f8685ca0..42ee3c81 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2570,6 +2570,117 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed; Children = $children } } +function Get-DynamicProjectCardSnapshotDecision( + [string] $surface, + [object[]] $cards, + [object[]] $expected, + [int] $processId, + [double[]] $graphBounds +) { + $reject = { + param([string] $failure) + return [ordered]@{ ready = $false; failure = $failure; cards = @($cards) } + } + if ($surface -cne "project") { return & $reject "wrong-surface" } + if ($processId -le 0) { return & $reject "invalid-process" } + if ($cards -isnot [array] -or $expected -isnot [array] -or + $cards.Count -ne $expected.Count -or $expected.Count -ne 2) { + return & $reject "card-count" + } + if ($graphBounds -isnot [array] -or $graphBounds.Count -ne 4) { + return & $reject "graph-bounds" + } + foreach ($coordinate in $graphBounds) { + if (($coordinate -isnot [int] -and $coordinate -isnot [long] -and + $coordinate -isnot [double] -and $coordinate -isnot [single] -and + $coordinate -isnot [decimal]) -or [double]::IsNaN([double]$coordinate) -or + [double]::IsInfinity([double]$coordinate)) { + return & $reject "graph-bounds" + } + } + if ($graphBounds[2] -le $graphBounds[0] -or $graphBounds[3] -le $graphBounds[1]) { + return & $reject "graph-bounds" + } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + for ($index = 0; $index -lt $cards.Count; $index++) { + $card = $cards[$index] + $target = $expected[$index] + if ($card -isnot [Collections.IDictionary] -or $card.Count -ne 4 -or + $target -isnot [Collections.IDictionary] -or $target.Count -ne 2) { + return & $reject "card-shape" + } + if (@($card.Keys | Where-Object { + @("automationId", "name", "processId", "bounds") -cnotcontains $_ + }).Count -ne 0 -or + @($target.Keys | Where-Object { + @("automationId", "name") -cnotcontains $_ + }).Count -ne 0) { + return & $reject "card-shape" + } + if ($card.automationId -isnot [string] -or -not $seen.Add($card.automationId)) { + return & $reject "duplicate-card" + } + if ($card.automationId -cne $target.automationId -or $card.name -cne $target.name) { + return & $reject "card-order" + } + if (($card.processId -isnot [int] -and $card.processId -isnot [long]) -or + $card.processId -ne $processId) { + return & $reject "foreign-card" + } + if ($card.bounds -isnot [array] -or $card.bounds.Count -ne 4) { + return & $reject "bounds-shape" + } + foreach ($coordinate in $card.bounds) { + if (($coordinate -isnot [int] -and $coordinate -isnot [long] -and + $coordinate -isnot [double] -and $coordinate -isnot [single] -and + $coordinate -isnot [decimal]) -or [double]::IsNaN([double]$coordinate) -or + [double]::IsInfinity([double]$coordinate)) { + return & $reject "bounds-shape" + } + } + if ($card.bounds[2] -le $card.bounds[0] -or $card.bounds[3] -le $card.bounds[1]) { + return & $reject "bounds-empty" + } + if ([Math]::Min($card.bounds[2], $graphBounds[2]) -le + [Math]::Max($card.bounds[0], $graphBounds[0]) -or + [Math]::Min($card.bounds[3], $graphBounds[3]) -le + [Math]::Max($card.bounds[1], $graphBounds[1])) { + return & $reject "outside-viewport" + } + } + $first = $cards[0].bounds + $second = $cards[1].bounds + if ([Math]::Min($first[2], $second[2]) -gt [Math]::Max($first[0], $second[0]) -and + [Math]::Min($first[3], $second[3]) -gt [Math]::Max($first[1], $second[1])) { + return & $reject "overlap" + } + return [ordered]@{ ready = $true; failure = $null; cards = @($cards) } +} + +function Get-DynamicProjectCardCapture( + [System.Windows.Automation.AutomationElement] $element +) { + $request = New-Object System.Windows.Automation.CacheRequest + $request.TreeScope = [System.Windows.Automation.TreeScope]::Element + $request.Add([System.Windows.Automation.AutomationElement]::ProcessIdProperty) + $request.Add([System.Windows.Automation.AutomationElement]::AutomationIdProperty) + $request.Add([System.Windows.Automation.AutomationElement]::NameProperty) + $request.Add([System.Windows.Automation.AutomationElement]::BoundingRectangleProperty) + $cached = $element.GetUpdatedCache($request) + $bounds = $cached.Cached.BoundingRectangle + return [ordered]@{ + automationId = [string]$cached.Cached.AutomationId + name = [string]$cached.Cached.Name + processId = [int]$cached.Cached.ProcessId + bounds = @( + [double]$bounds.Left, + [double]$bounds.Top, + [double]$bounds.Right, + [double]$bounds.Bottom + ) + } +} + # A modal teardown (e.g. dismissing the update-offer dialog via SendCommand) # rebuilds the shell's fragment tree, and a $root captured before that teardown # can become a permanently dead reference - not a transient blip that @@ -6507,28 +6618,101 @@ try { ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) $null = Assert-FragmentLinks $loops $rawWalker $loopChildIds "RawView Loops" $null = Assert-FragmentLinks $loops $controlWalker $loopChildIds "ControlView Loops" - $projectCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^canvas-card-' -and - $_.Current.Name -in @("UIA loop A", "UIA loop B") + # Canvas cards are not virtualized sidebar rows and intentionally do not expose + # ScrollItem. Normalize the camera through the source-owned Fit Canvas action, + # then reacquire the graph and freeze one exact two-card publication before + # reading geometry. Holding the pre-sidebar graph/card handles here samples an + # arbitrary later provider state, which is the DPI192 empty-bounds cutoff this + # phase is meant to prevent. + $dynamicProjectNodeIds = @( + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ) + $dynamicProjectExpected = @( + [ordered]@{ + automationId = Get-MultiProjectAutomationId "project-card" $fixtureProjectPath $dynamicProjectNodeIds[0] + name = "UIA loop A" + }, + [ordered]@{ + automationId = Get-MultiProjectAutomationId "project-card" $fixtureProjectPath $dynamicProjectNodeIds[1] + name = "UIA loop B" + } + ) + $fitProjectCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require (($null -ne $fitProjectCanvas) -and + ($fitProjectCanvas.Current.ProcessId -eq $process.Id) -and + ($fitProjectCanvas.Current.Name -ceq "Fit canvas")) ` + "dynamic project card normalization omitted the exact owned Fit Canvas action" + $fitProjectCanvas.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $dynamicProjectDecision = $null + $dynamicProjectChildren = @() + $dynamicProjectElements = @() + $graph = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentById $root "graph" $rawWalker + if ($null -ne $graph) { + $dynamicProjectChildren = @(Get-DirectChildren $graph $rawWalker) + $dynamicProjectElements = @($dynamicProjectChildren | Where-Object { + ($_.Current.AutomationId -cin @($dynamicProjectExpected | ForEach-Object { $_.automationId })) -or + ($_.Current.Name -cin @($dynamicProjectExpected | ForEach-Object { $_.name })) + }) + $dynamicProjectCaptures = @($dynamicProjectElements | ForEach-Object { + Get-DynamicProjectCardCapture $_ + }) + $graphBounds = $graph.Current.BoundingRectangle + $dynamicProjectDecision = Get-DynamicProjectCardSnapshotDecision "project" ` + $dynamicProjectCaptures $dynamicProjectExpected $process.Id @( + [double]$graphBounds.Left, + [double]$graphBounds.Top, + [double]$graphBounds.Right, + [double]$graphBounds.Bottom + ) + if ($dynamicProjectDecision.ready) { break } + } + Start-Sleep -Milliseconds 100 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + } + Require (($null -ne $dynamicProjectDecision) -and $dynamicProjectDecision.ready) ` + "dynamic project cards did not converge after bounded Fit Canvas normalization/reacquisition: $($dynamicProjectDecision.failure)" + $projectCards = @($dynamicProjectExpected | ForEach-Object { + $expectedId = $_.automationId + @($dynamicProjectElements | Where-Object { + $_.Current.AutomationId -ceq $expectedId + })[0] }) - Require (($projectCards.Count -eq 2) -and - ((@($projectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Graph did not expose synchronized project cards" + $projectCardIds = @($dynamicProjectDecision.cards | ForEach-Object { $_.automationId }) + Write-Host ("UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=" + ([ordered]@{ + surface = "project" + normalizedBy = "fit-canvas" + attempts = $attempt + 1 + processId = $process.Id + graphBounds = @( + [double]$graphBounds.Left, + [double]$graphBounds.Top, + [double]$graphBounds.Right, + [double]$graphBounds.Bottom + ) + cards = $dynamicProjectDecision.cards + exactOrder = $projectCardIds + nonOverlapping = $true + viewportIntersectionRequired = $true + } | ConvertTo-Json -Compress -Depth 5)) foreach ($card in $projectCards) { - Require (($card.Current.BoundingRectangle.Width -gt 0) -and - ($card.Current.BoundingRectangle.Height -gt 0)) "dynamic project card has empty bounds" $null = $card.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) } - $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) - $attentionAction = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $attentionAction = @($dynamicProjectChildren | Where-Object { $_.Current.AutomationId -match '^attention-action-' -and $_.Current.Name -eq "Reply" }) | Select-Object -First 1 Require ($null -ne $attentionAction) "NEEDS YOU card omitted its reason-specific Reply action" Require (($attentionAction.Current.BoundingRectangle.Width -gt 0) -and ($attentionAction.Current.BoundingRectangle.Height -gt 0)) "Reply attention action had empty bounds" $null = $attentionAction.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) - $reclaimOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Reclaim" }) | Select-Object -First 1 - $keepOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Keep" }) | Select-Object -First 1 + $reclaimOffer = @($dynamicProjectChildren | Where-Object { $_.Current.Name -eq "Reclaim" }) | Select-Object -First 1 + $keepOffer = @($dynamicProjectChildren | Where-Object { $_.Current.Name -eq "Keep" }) | Select-Object -First 1 Require ($null -ne $reclaimOffer) "resolved card with a reclaimable worktree omitted its Reclaim descendant" Require ($null -ne $keepOffer) "resolved card with a reclaimable worktree omitted its Keep descendant" Require (($reclaimOffer.Current.BoundingRectangle.Width -gt 0) -and @@ -6537,7 +6721,7 @@ try { ($keepOffer.Current.BoundingRectangle.Height -gt 0)) "Keep descendant had empty bounds" $null = $reclaimOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $null = $keepOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) - $railReviews = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $railReviews = @($dynamicProjectChildren | Where-Object { $_.Current.AutomationId -match '^canvas-attention-review-' }) Require ($railReviews.Count -eq 1) "attention rail omitted its single Review button"