From b27dd78822ca2ba1687c77376486029bb4ae6b33 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 11:30:26 -0700 Subject: [PATCH] Fix C8R many-tab UIA selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCaptureWorker.ps1 | 69 +++++++++++++-- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 85 ++++++++++++++++--- Tools/windows/UI-CAPTURE-SCENARIOS.md | 4 +- Tools/windows/UiCaptureDriver.psm1 | 43 +++++++++- 4 files changed, 177 insertions(+), 24 deletions(-) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 47950046..193df350 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -1579,6 +1579,58 @@ function Read-CapMatchedRows { }) } +function Invoke-CapMatchedTabSelection { + param( + [Parameter(Mandatory)]$AppRun, + [Parameter(Mandatory)][ValidateRange(1,12)][int]$TotalTabs, + [Parameter(Mandatory)][ValidateRange(0,11)][int]$SelectedTab, + [Parameter(Mandatory)][ValidateRange(0,11)][int]$TargetIndex, + [Parameter(Mandatory)][string]$AgentTitle, + [Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{40}$')][string]$SourceCommit, + [Parameter(Mandatory)][long]$ExpectedDpi, + [Parameter(Mandatory)][long]$DpiBase + ) + if ($SelectedTab -ge $TotalTabs -or $TargetIndex -ge $TotalTabs) { + throw 'Matched tab selection index is outside the declared topology.' + } + $observe = { + param([int]$ExpectedSelected) + $rows = @(Read-CapMatchedRows -AppRun $AppRun) + Get-CapMatchedTabObservation -Rows $rows -ExpectedProcessId $AppRun.Run.Identity.Pid ` + -TotalTabs $TotalTabs -SelectedTab $ExpectedSelected -AgentTitle $AgentTitle ` + -SourceCommit $SourceCommit -ExpectedDpi $ExpectedDpi -DpiBase $DpiBase + } + $before = & $observe $SelectedTab + $visible = @($before.visible.index) + $bridge = $null + if ($TargetIndex -notin $visible) { + $first = [int]$visible[0] + $last = [int]$visible[-1] + $bridge = if ($TargetIndex -lt $first) {$first} elseif ($TargetIndex -gt $last) {$last} else { + throw 'Matched tab target is absent from a non-contiguous visible overflow window.' + } + $bridgeName = if ($bridge -eq 0) {$AgentTitle} else {'Shell'} + $null = Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' ` + -SubjectId ([string]$bridge) -Name $bridgeName + $afterBridge = & $observe $bridge + if ($TargetIndex -notin @($afterBridge.visible.index)) { + throw "Matched tab $TargetIndex remained genuinely unavailable after exact overflow bridge $bridge." + } + } + $targetName = if ($TargetIndex -eq 0) {$AgentTitle} else {'Shell'} + $null = Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' ` + -SubjectId ([string]$TargetIndex) -Name $targetName + $after = & $observe $TargetIndex + return [pscustomobject]@{ + target=$TargetIndex + bridge=$bridge + beforeVisible=$visible + afterVisible=@($after.visible.index) + selected=$after.selected + contract='EXACT_VISIBLE_ID_BRIDGE_REACQUIRE_EXACT_TARGET_ID' + } +} + function Read-CapMatchedPanes { param($AppRun,[int]$ExpectedCount,[int]$FocusedIndex=-1) $null=Assert-CapRootProcess -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity @@ -1869,6 +1921,7 @@ function Invoke-CapMatchedScenes { } else { $null=Invoke-CapSceneAction -AppRun $app -Action 'open-loop' -Name $node.title $total=1;$selected=0;$title=switch ($nodeIndex) {0 {'Goal'};6 {'Loop'};7 {'Agent'};default {'Copilot CLI'}} + $tabSelection=$null $newTabs=if ($scene -cin @('s-tabs-many-first','s-tabs-overflow-last')) {11} elseif ($scene -cin @('s-tabs-two-shell-selected','s-tabs-split-right-focused')) {2} elseif ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close')) {1} else {0} @@ -1880,17 +1933,20 @@ function Invoke-CapMatchedScenes { $selected=1 } if ($scene -cin @('s-tabs-two-agent-selected','s-tabs-hover-close','s-tabs-many-first')) { - if ($total -eq 12) {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '2' -Name 'Shell'} - $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '0' -Name $title + if ($total -eq 12) { + $tabSelection=Invoke-CapMatchedTabSelection -AppRun $app -TotalTabs $total ` + -SelectedTab $selected -TargetIndex 0 -AgentTitle $title ` + -SourceCommit $job.binaries.$Phase.source ` + -ExpectedDpi $tabExpectedDpi -DpiBase $tabDpiBase + } else { + $null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-select-tab' -SubjectId '0' -Name $title + } $selected=0 } if ($scene -ceq 's-tabs-split-right-focused') {$null=Invoke-CapSceneAction -AppRun $app -Action 'workspace-split-right'} $rows=@(Read-CapMatchedRows -AppRun $app) - $visibleCount=[Math]::Min(10,$total) - $first=if ($selected -ge $visibleCount) {$selected-$visibleCount+1} else {0} - $visible=@($first..($first+$visibleCount-1)) $tabs=Get-CapMatchedTabObservation -Rows $rows -ExpectedProcessId $app.Run.Identity.Pid ` - -TotalTabs $total -SelectedTab $selected -AgentTitle $title -VisibleIndexes $visible ` + -TotalTabs $total -SelectedTab $selected -AgentTitle $title ` -SourceCommit $job.binaries.$Phase.source ` -ExpectedDpi $tabExpectedDpi -DpiBase $tabDpiBase if ($scene -ceq 's-tabs-hover-close') { @@ -1902,6 +1958,7 @@ function Invoke-CapMatchedScenes { if (@($panes | Where-Object focused).Count -ne 1) {throw 'Matched selected pane did not acquire the reported UIA focus.'} $app.SObservation=[pscustomobject]@{readSucceeded=$true;evidenceMode='OWNED_UIA_READ_NOT_VISUAL_PARITY' tabs=$tabs;panes=$panes;nodeId=$node.id;nodeState=$node.state + tabSelection=$tabSelection hover=if ($scene -ceq 's-tabs-hover-close') {'POSTED_WM_MOUSEMOVE_NOT_PHYSICAL_POINTER'} else {'NOT_REQUESTED'}} Save-CapSceneImage -AppRun $app -SceneName $scene -ZoomEvidence $null -Phase $Phase } diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 1b7e68cd..5a185b6f 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -3328,12 +3328,17 @@ function Invoke-CapMatchedDpiContracts { function Invoke-CapMatchedTabContracts { $appSource=[IO.File]::ReadAllText((Join-Path $driverDirectory '..\..\graphcode-windows\src\App.zig')) $terminalSource=[IO.File]::ReadAllText((Join-Path $driverDirectory '..\..\graphcode-windows\src\TerminalSurface.zig')) + $accessibilitySource=[IO.File]::ReadAllText((Join-Path $driverDirectory '..\..\graphcode-windows\src\Accessibility.zig')) $providerSource=[IO.File]::ReadAllText((Join-Path $driverDirectory '..\..\graphcode-windows\src\AccessibilityProvider.cpp')) Assert-CapTest ($appSource.Contains( 'self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = close_bounds }, false, workspace.canCloseTab())') -and + $appSource.Contains('const tab_bounds = workspace.visibleTabBounds(tab_index) orelse continue;') -and $appSource.Contains('if (workspace.tabCloseBounds(tab_index) == null or !workspace.canCloseTab()) return false;') -and $terminalSource.Contains('bounds.right - Dpi.scale(24, self.dpi)') -and + $terminalSource.Contains('const first = if (selected >= count) selected - count + 1 else 0;') -and $terminalSource -cmatch '(?s)pub fn canCloseTab\(self: \*const Workspace\) bool \{\s*return self\.layout\.tabs\.items\.len > 1;\s*\}' -and + $accessibilitySource.Contains('sidebar_scroll: bool = false,') -and + $providerSource -cmatch '(?s)bool supportsScrollItem\(\) const \{.*?if \(isRowKey\(id_\)\) return state_->rows\.at\(id_\)\.scrolls;' -and $providerSource -cmatch '(?s)if \(isRowKey\(id_\)\) \{.*?bool_value = sidebar_error_footer.*?: true;') ` 'Product tab close source contract no longer matches the capture interpretation.' foreach ($vector in @( @@ -3343,7 +3348,7 @@ function Invoke-CapMatchedTabContracts { )) { Assert-CapTest ((Get-CapMatchedAutomationId $vector[0]) -ceq $vector[1]) 'Native row-prefix/hash reference vector differs.' } - $positive=0;$negative=0;$failedSeed=$null + $positive=0;$negative=0;$failedSeed=$null;$failedManyFirst=$null foreach ($shape in @( @{phase='before';scene='s-tabs-goal-needs-you';source='a8b09434fe41a61030fd290f7d065ec6d0db8005' total=1;selected=0;title='Goal';visible=@(0);closeWidth=48;failedSeed=$true}, @@ -3353,9 +3358,11 @@ function Invoke-CapMatchedTabContracts { @{total=2;selected=0;title='Copilot CLI';visible=@(0,1)}, @{total=2;selected=1;title='Copilot CLI';visible=@(0,1)}, @{total=12;selected=0;title='Copilot CLI';visible=@(0..9)}, - @{total=12;selected=11;title='Copilot CLI';visible=@(2..11)} + @{phase='before';scene='s-tabs-many-first';source='a8b09434fe41a61030fd290f7d065ec6d0db8005' + total=12;selected=11;title='Copilot CLI';visible=@(4..11);failedManyFirst=$true} )) { $isFailedSeed=$shape.ContainsKey('failedSeed') -and $shape.failedSeed + $isFailedManyFirst=$shape.ContainsKey('failedManyFirst') -and $shape.failedManyFirst $rows=@(foreach ($position in 0..($shape.visible.Count-1)) { $index=$shape.visible[$position];$x=250+110*$position $closeWidth=if ($shape.ContainsKey('closeWidth')) {$shape.closeWidth} else {48} @@ -3368,9 +3375,10 @@ function Invoke-CapMatchedTabContracts { Bounds=@(($x+106-$closeWidth),100,($x+106),124)} }) | ConvertTo-Json -Depth 10 | ConvertFrom-Json $arguments=@{Rows=$rows;ExpectedProcessId=4242;TotalTabs=$shape.total;SelectedTab=$shape.selected - AgentTitle=$shape.title;VisibleIndexes=$shape.visible - SourceCommit=$(if ($isFailedSeed) {$shape.source} else {'65a85781c1366f9c3d2398e264ab2df9220c7f8c'}) + AgentTitle=$shape.title + SourceCommit=$(if ($isFailedSeed -or $isFailedManyFirst) {$shape.source} else {'65a85781c1366f9c3d2398e264ab2df9220c7f8c'}) ExpectedDpi=192;DpiBase=96} + if (-not $isFailedManyFirst) {$arguments.VisibleIndexes=$shape.visible} $proof=Get-CapMatchedTabObservation @arguments Assert-CapTest ($proof.visible.Count -eq $shape.visible.Count -and $proof.selected -eq $shape.selected) 'Tab shape omitted a visible state.' if ($isFailedSeed) { @@ -3383,10 +3391,26 @@ function Invoke-CapMatchedTabContracts { bounds=$proof.visible[0].bounds;closeBounds=$proof.visible[0].closeBounds closeBoundsContract=$proof.closeBoundsContract} } + if ($isFailedManyFirst) { + Assert-CapTest (($proof.visible.index -join ',') -ceq '4,5,6,7,8,9,10,11') ` + 'Exact failed many-first roster did not retain the observed DPI192 overflow window.' + $failedManyFirst=[pscustomobject]@{ + lease='94dd0b32-0169-492e-8233-af1b571e61b4' + manifestSha256='07B23692C55145F1FCD5D4C2A03D522ABFFE9F56AEAD36954BF143873D5669D2' + jobSha256='002629F80DF2CD0221B76FCD8169C21F98E16DC542721D301BD29B4796FC6630' + fixtureMappingSha256='6682206FC4EC0558307B62888BA008D24A16EF40C30A25DF1C929FC94FBED838' + phase=$shape.phase;scene=$shape.scene;source=$shape.source;dpi=192 + selected=$shape.selected;visible=@($proof.visible.index) + unavailableIndex=2 + unavailableAutomationId=Get-CapMatchedAutomationId 'workspace-tab:2' + terminalFailure="UIA element 'Shell' did not become available before the reserved deadline." + } + } $positive++ foreach ($mutation in @('foreign-pid','wrong-parent','wrong-identity','wrong-name','wrong-selection', - 'wrong-order','escaped-close','foreign-close','guard','duplicate','hidden','offscreen')) { + 'wrong-order','missing','escaped-close','foreign-close','guard','duplicate','hidden','offscreen')) { if ($mutation -ceq 'wrong-order' -and $shape.visible.Count -lt 2) {continue} + if ($mutation -ceq 'missing' -and $shape.visible.Count -lt 3) {continue} $changed=$rows | ConvertTo-Json -Depth 10 | ConvertFrom-Json switch ($mutation) { 'foreign-pid' {$changed[0].ProcessId=7777} @@ -3400,6 +3424,7 @@ function Invoke-CapMatchedTabContracts { $changed[2].Bounds=@($right,[double]$changed[2].Bounds[1],$nextRight,[double]$changed[2].Bounds[3]) $changed[3].Bounds=@($closeLeft,[double]$changed[2].Bounds[1],$nextRight,[double]$changed[2].Bounds[3]) } + 'missing' {$changed=@($changed[0..1]+$changed[4..($changed.Count-1)])} 'escaped-close' {$changed[1].Bounds[0]=$changed[0].Bounds[0]-1} 'foreign-close' { $foreign=$changed[1] | ConvertTo-Json -Depth 10 | ConvertFrom-Json @@ -3416,8 +3441,8 @@ function Invoke-CapMatchedTabContracts { $negative++ } } - Assert-CapTest ($null -ne $failedSeed) 'Exact failed C8R job/seed observation did not execute.' - return @{positiveShapes=$positive;negativeMutations=$negative;failedSeed=$failedSeed + Assert-CapTest ($null -ne $failedSeed -and $null -ne $failedManyFirst) 'Exact failed C8R job/scene observations did not execute.' + return @{positiveShapes=$positive;negativeMutations=$negative;failedSeed=$failedSeed;failedManyFirst=$failedManyFirst sourceContract='APP_CLOSE_ELIGIBILITY_AND_ROUTE_PLUS_WORKSPACE_GT_ONE_WITH_UIA_DYNAMIC_ROWS_ENABLED' nativeHover='NotExecuted';paneFocus='NotExecuted' evidence='SYNTHETIC_UIA_RECORDS_NOT_NATIVE_OBSERVATION'} @@ -3948,11 +3973,14 @@ function Invoke-CapSeedPathContracts { function Invoke-CapMatchedSceneRoutingContract { $ast=[Management.Automation.Language.Parser]::ParseFile($workerPath,[ref]$null,[ref]$null) + $selectionFunction=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-CapMatchedTabSelection'},$true) $function=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Invoke-CapMatchedScenes'},$true) - Assert-CapTest ($null -ne $function) 'Actual matched scene executor is missing.' + Assert-CapTest ($null -ne $selectionFunction -and $null -ne $function) 'Actual matched tab selection or scene executor is missing.' & { - param($Definition) + param($SelectionDefinition,$Definition) + . ([scriptblock]::Create($SelectionDefinition)) . ([scriptblock]::Create($Definition)) $root=Join-Path $scratch 'matched-route-component' $runDirectory=Join-Path $root 'runs' @@ -3966,7 +3994,14 @@ function Invoke-CapMatchedSceneRoutingContract { $leaseDeadlineUtc=[DateTime]::UtcNow.AddMinutes(5) $captured=[Collections.Generic.List[object]]::new() $seeded=[Collections.Generic.List[string]]::new() - $model=[pscustomobject]@{total=1;selected=0;node=$null;removed=$false;restart=0;hover=0} + $model=[pscustomobject]@{total=1;selected=0;node=$null;removed=$false;restart=0;hover=0;freezeVisibility=$false + selections=[Collections.Generic.List[string]]::new()} + function Get-MockVisibleIndexes { + if ($model.freezeVisibility) {return @(4..11)} + $count=[Math]::Min($(if ($model.total -eq 12) {8} else {10}),$model.total) + $first=if ($model.selected -ge $count) {$model.selected-$count+1} else {0} + return @($first..($first+$count-1)) + } function Assert-CapWorkerDeadline {param($Label)} function Initialize-CapPhaseRoot {param($PhaseRoot) [pscustomobject]@{Root=$PhaseRoot}} function Start-CapOfflinePeer {param($Roots,$PipeName,$Phase) [pscustomobject]@{mode='SIMULATED_PEER'}} @@ -3982,7 +4017,13 @@ function Invoke-CapMatchedSceneRoutingContract { 'open-loop' {$model.node=@($fixture.graphs[0].nodes | Where-Object title -CEQ $Name)[0]} 'workspace-new-tab' {$model.total++;$model.selected=$model.total-1} 'workspace-close-tab' {$model.total--;$model.selected=0;$model.removed=$true} - 'workspace-select-tab' {$model.selected=[int]$SubjectId} + 'workspace-select-tab' { + if ([int]$SubjectId -notin @(Get-MockVisibleIndexes)) { + throw "UIA element '$Name' did not become available before the reserved deadline." + } + $model.selections.Add("${scene}:$SubjectId") + $model.selected=[int]$SubjectId + } } } function Read-CapMatchedRows { @@ -3994,8 +4035,9 @@ function Invoke-CapMatchedSceneRoutingContract { Bounds=@(250,100,500,196)} }) } - $count=[Math]::Min(10,$model.total) - $first=if ($model.selected -ge $count) {$model.selected-$count+1} else {0} + $visible=@(Get-MockVisibleIndexes) + $count=$visible.Count + $first=$visible[0] $title=if ($model.removed) {'Shell'} else {switch ($model.node.loopType) { 'goalBased' {'Goal'};'timeBased' {'Loop'};default {if ($model.node.PSObject.Properties['backend']) {'Copilot CLI'} else {'Agent'}} }} @@ -4028,9 +4070,24 @@ function Invoke-CapMatchedSceneRoutingContract { Invoke-CapMatchedScenes -Phase 'after' Assert-CapTest ($captured.Count -eq 17 -and $seeded.Count -eq 11 -and $model.restart -eq 1 -and $model.hover -eq 1 -and ($captured.scene -join ',') -ceq (((Get-CapMatchedSceneNames)[0..16]) -join ',')) 'Actual executor lost a scene, seed, Restart dispatch or hover.' + $many=@($captured | Where-Object scene -CEQ 's-tabs-many-first')[0] + $manySelections=@($model.selections | Where-Object {$_ -cmatch '^s-tabs-many-first:'}) + Assert-CapTest (($many.observation.tabs.visible.index -join ',') -ceq '0,1,2,3,4,5,6,7' -and + ($manySelections -join ',') -ceq 's-tabs-many-first:4,s-tabs-many-first:0') ` + 'Many-first did not bridge through the exact lowest visible tab and reacquire the requested first tab.' + $model.total=12;$model.selected=11;$model.node=$fixture.graphs[0].nodes[5];$model.removed=$false + $model.freezeVisibility=$true + $scene='s-tabs-many-first-unavailable' + $unavailable=Assert-CapRejects { + Invoke-CapMatchedTabSelection -AppRun ([pscustomobject]@{ + Run=[pscustomobject]@{Identity=[pscustomobject]@{Pid=4242}} + }) -TotalTabs 12 -SelectedTab 11 -TargetIndex 0 -AgentTitle 'Copilot CLI' ` + -SourceCommit $job.binaries.after.source -ExpectedDpi 96 -DpiBase 96 + } 'remained genuinely unavailable after exact overflow bridge 4' return @{scenes=$captured.Count;seedDispatches=$seeded.Count;realRestartDispatches=$model.restart;hoverDispatches=$model.hover + manyFirstSelections=$manySelections;unavailableFailure=$unavailable evidence='ACTUAL_WORKER_FUNCTION_SIMULATED_DEPENDENCIES_NO_GUI_NO_PROVIDER_NO_IMAGES'} - } $function.Extent.Text + } $selectionFunction.Extent.Text $function.Extent.Text } function Invoke-CapMatchedRestartRouteContract { diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index c643fe90..f111e69a 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -281,7 +281,7 @@ combined-tree count update and registered-runner rerun. | Typed fixture inventory | Eight wire nodes, six qualified card-state records; unchanged graph publication through the actual B peer on a real local pipe; actual worker persistence of the isolated Restart graph and recent-project index | | Fixture refusals | Fourteen identity/type/status/wiring/reason/selection/resolution/size/branch/source/date/duplicate/required-project-date mutations | | Scene inventory | One accepted complete inventory; eighteen omission refusals; actual worker's seventeen static-scene/eleven seed/one Restart dispatches with simulated dependencies | -| Tab observations | Eight positive shapes and seventy-two negative mutations of exact owned IDs, parents, selection, close bounds, final-tab eligibility and visibility | +| Tab observations | Eight positive shapes and ninety-four negative mutations of exact owned IDs, parents, names, selection, order, missing/foreign/duplicate rows, close bounds, final-tab eligibility and visibility | | Restart evidence | Two simulated positive transcripts (including UUID casing and response-embedded graph data) and nineteen strict refusal mutations | | Restart route | Actual worker function, one simulated positive and one offline-authority refusal; actual pending helper returns null for two incomplete files, retains both diagnostics, and loudly refuses malformed complete JSON | | Transparent recorder | Four unchanged framed messages on actual local pipes, one connection, a simulated echo upstream; no daemon or UI | @@ -330,7 +330,7 @@ observed UI state, not a `selected` property invented on the daemon's node. | `s-tabs-two-agent-selected` | Two tabs; blocked agent selected, Shell unselected; the agent's attention indicator remains contextual. | | `s-tabs-two-shell-selected` | Two actual Shell tabs after closing the agent tab, second selected. | | `s-tabs-split-right-focused` | Two Shell tabs, selected tab split horizontally with the right pane explicitly focused; safe M3 context only. | -| `s-tabs-many-first`, `s-tabs-overflow-last` | Twelve tabs; first-selected and last-selected overflow windows. Hidden IDs may not appear in the UIA roster. Ctrl+1 through Ctrl+9 have inline hints; later indexes do not invent one. | +| `s-tabs-many-first`, `s-tabs-overflow-last` | Twelve tabs; first-selected and last-selected overflow windows. Hidden IDs may not appear in the UIA roster, and tab rows do not claim the sidebar-only ScrollItem contract. The first-selected route derives the exact contiguous visible stable-ID window, invokes its nearest edge tab, reacquires the roster, then invokes the exact first-tab ID. Ctrl+1 through Ctrl+9 have inline hints; later indexes do not invent one. | | `s-tabs-hover-close` | Two tabs; posted owned-window hover over Shell, close geometry retained. This is not physical mouse input. | | `s-tabs-last-guard` | One selected tab; the exact visible close row is retained, while the phase's source-bound `Workspace.canCloseTab()` contract proves that one tab cannot close. Dynamic UIA rows report enabled independently of that product guard, and capture does not attempt the close. | | `s-restart-confirmed` | Existing Restart session UIA route against an actual isolated daemon, with same-connection protocol and new owned-attach evidence described below. | diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index a1adc1b5..3abdb7ee 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -3635,12 +3635,50 @@ int main(void) { '@ } +function Get-CapMatchedVisibleTabIndexes { + param( + [Parameter(Mandatory)][object[]]$Rows, + [Parameter(Mandatory)][ValidateRange(1, 12)][int]$TotalTabs + ) + $indexById = @{} + foreach ($index in 0..($TotalTabs - 1)) { + $id = Get-CapMatchedAutomationId "workspace-tab:$index" + if ($indexById.ContainsKey($id)) { throw 'Matched tabs stable identities collide.' } + $indexById[$id] = $index + } + $visible = [Collections.Generic.List[int]]::new() + $seen = [Collections.Generic.HashSet[int]]::new() + foreach ($row in @($Rows | Where-Object AutomationId -CMatch '^workspace-tab-[0-9]+$')) { + $id = [string]$row.AutomationId + if (-not $indexById.ContainsKey($id)) { + throw 'Matched tabs roster contains hidden, duplicate or foreign tab identities.' + } + $index = [int]$indexById[$id] + if (-not $seen.Add($index)) { + throw 'Matched tabs roster contains hidden, duplicate or foreign tab identities.' + } + $visible.Add($index) + } + if ($visible.Count -eq 0) { throw 'Matched tabs have no visible stable identities.' } + $ordered = @($visible | Sort-Object) + for ($position = 1; $position -lt $ordered.Count; $position++) { + if ($ordered[$position] -ne $ordered[$position - 1] + 1) { + throw 'Matched tabs visible overflow window is not contiguous.' + } + } + return $ordered +} + function Get-CapMatchedTabObservation { param([Parameter(Mandatory)][object[]]$Rows,[Parameter(Mandatory)][int]$ExpectedProcessId, [Parameter(Mandatory)][int]$TotalTabs,[Parameter(Mandatory)][int]$SelectedTab, - [Parameter(Mandatory)][string]$AgentTitle,[Parameter(Mandatory)][int[]]$VisibleIndexes, + [Parameter(Mandatory)][string]$AgentTitle,[int[]]$VisibleIndexes, [Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{40}$')][string]$SourceCommit, [Parameter(Mandatory)][long]$ExpectedDpi,[Parameter(Mandatory)][long]$DpiBase) + $visibilityDerived = -not $PSBoundParameters.ContainsKey('VisibleIndexes') + if ($visibilityDerived) { + $VisibleIndexes = @(Get-CapMatchedVisibleTabIndexes -Rows $Rows -TotalTabs $TotalTabs) + } if ($TotalTabs -lt 1 -or $SelectedTab -lt 0 -or $SelectedTab -ge $TotalTabs -or $VisibleIndexes.Count -lt 1 -or $SelectedTab -notin $VisibleIndexes -or @($VisibleIndexes | Sort-Object -Unique).Count -ne $VisibleIndexes.Count) {throw 'Matched tabs invalid declared topology.'} @@ -3688,6 +3726,7 @@ function Get-CapMatchedTabObservation { } return [pscustomobject]@{total=$TotalTabs;selected=$SelectedTab;visible=@($observed) sourceCommit=$SourceCommit;identityContract='VISIBLE_LAYOUT_INDEX_FNV1A64_ROW_KEY' + visibilityContract=$(if ($visibilityDerived) {'DERIVED_CONTIGUOUS_OWNED_VISIBLE_STABLE_IDS'} else {'DECLARED_VISIBLE_STABLE_IDS'}) closeBoundsContract="SOURCE_DPI_SCALED_24_LOGICAL_TO_${expectedCloseWidth}_PHYSICAL" closeGuardContract='WORKSPACE_CAN_CLOSE_TAB_GT_ONE_NOT_UIA_ISENABLED' evidenceMode='OWNED_UIA_STATE_NOT_GLYPHS_OR_VISUAL_PARITY';hover='VISUAL_ONLY_NOT_EXPOSED_BY_UIA'} @@ -5626,7 +5665,7 @@ function Assert-CapNoWorkerTransientArtifacts { Export-ModuleMember -Function @( 'Get-CapMatchedSceneNames','Assert-CapMatchedFixture','Get-CapMatchedAutomationId', - 'Get-CapMatchedTabObservation','Assert-CapRestartEvidence','Initialize-CapMatchedProxy', + 'Get-CapMatchedVisibleTabIndexes','Get-CapMatchedTabObservation','Assert-CapRestartEvidence','Initialize-CapMatchedProxy', 'Read-CapRestartTranscript','Assert-CapMatchedManifest','Get-CapMatchedBackendSource', 'Get-CapDSceneNames', 'Assert-CapDFixture',