diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 20914ad5..473c5c64 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -9,6 +9,7 @@ param( $ErrorActionPreference = "Stop" Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName System.Drawing Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; @@ -340,6 +341,32 @@ public static class GraphCodeUiaGateState { return true; }, IntPtr.Zero); } + // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren + // exhaustion path): enumerates every top-level window currently owned by + // processId with its class, visibility, and title, so a failure that + // reports MainWindowHandle == 0 can be told apart as window-destroyed + // (empty result), window-hidden (a result exists but is not visible), or + // handle-churn (a visible, healthy-looking window exists under a handle + // Process.MainWindowHandle no longer reports). Does not filter or assert + // anything - it is read verbatim into a log line. + public static string[] DescribeTopLevelWindows(uint processId) { + var results = new System.Collections.ArrayList(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var classText = new StringBuilder(256); + GetClassName(window, classText, classText.Capacity); + int titleLength = GetWindowTextLength(window); + var titleText = new StringBuilder(titleLength + 1); + GetWindowText(window, titleText, titleText.Capacity); + bool visible = IsWindowVisible(window); + results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + + (visible ? "visible" : "hidden") + ":" + titleText.ToString()); + return true; + }, IntPtr.Zero); + return (string[])results.ToArray(typeof(string)); + } public static bool PostMouseClick(IntPtr window) { return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); } @@ -350,13 +377,33 @@ public static class GraphCodeUiaGateState { public static bool WindowIsEnabled(IntPtr window) { return IsWindowEnabled(window); } + // Real client-coordinate mouse messages posted directly to the target window, + // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS + // delivers for genuine mouse input, without moving the shared desktop's real + // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets + // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP + // carries; ClickAt below (and its existing sidebar-update-banner caller) keep + // working unchanged since App.zig's click handling only decodes the lParam + // x/y, not the button-state bits in wParam. + private static IntPtr MouseLParam(int x, int y) { + return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); + } public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { - IntPtr lparam = (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); - return PostMessage(window, message, UIntPtr.Zero, lparam); + return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); } public static bool ClickAt(IntPtr window, int x, int y) { return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); } + public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); + } + public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { + return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { + return PostMouseButtonAt(window, 0x0201, clientX, clientY) && + PostMouseButtonAt(window, 0x0202, clientX, clientY); + } // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with // no UIA identity of their own, so a genuine click requires the real live client // height rather than an assumed window size. @@ -389,6 +436,20 @@ public static class GraphCodeUiaGateState { public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); } + [StructLayout(LayoutKind.Sequential)] + public struct ScreenPoint { public int X; public int Y; } + [DllImport("user32.dll")] + private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr window); + public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } + public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { + var point = new ScreenPoint { X = screenX, Y = screenY }; + bool ok = ScreenToClient(window, ref point); + clientX = point.X; + clientY = point.Y; + return ok; + } public static bool PostCommand(IntPtr window, uint command) { return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); } @@ -717,17 +778,159 @@ function Ensure-ShellForeground( return $acquired } +# Dropped the ElementNotAvailableException retry-and-swallow this function previously +# had: catching it and returning @() after a few attempts makes every downstream +# "-eq 0" / "-not" assertion built on this (e.g. Assert-FragmentLinks's +# unexpected-children check) vacuously pass when the parent is transiently or +# permanently unavailable, instead of surfacing the real failure. There was no +# RED/GREEN evidence backing that retry, and it also explains three different failure +# points observed across otherwise byte-identical CI runs of this branch: which +# assertion an absorbed ENA surfaces at depends on where in the tree walk it happened +# to land. A dead parent now throws immediately; callers that genuinely need to +# tolerate a remounting parent (e.g. Wait-ForGraphChildren) already re-resolve the +# parent each attempt around this call instead of masking it here. +# +# The COMException retry is kept, narrowly: with the ENA swallow removed, CI +# reproduced a real "Catastrophic failure (0x8000FFFF (E_UNEXPECTED))" from +# GetFirstChild seconds after the freshly-launched shell's UI Automation provider +# registered, before any assertion ran - i.e. a raw framework call failing, not a +# silently-passing check. That is exactly the transient class the original comment +# described. Unlike the ENA case, this can't make an assertion vacuous: it always +# throws (never returns a masking @()) unless it genuinely recovers within budget. function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker ) { - $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] - $child = $walker.GetFirstChild($element) - while ($null -ne $child) { - $children.Add($child) - $child = $walker.GetNextSibling($child) + $attempt = 0 + $deadline = (Get-Date).AddMilliseconds(5000) + while ($true) { + try { + $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] + $child = $walker.GetFirstChild($element) + while ($null -ne $child) { + $children.Add($child) + $child = $walker.GetNextSibling($child) + } + return @($children.ToArray()) + } catch { + # A direct .NET method call (e.g. $walker.GetFirstChild(...)) that throws is + # unwrapped by a *typed* catch clause, but a *bare* catch instead receives it + # wrapped in System.Management.Automation.MethodInvocationException, with the + # real exception (e.g. COMException) in .InnerException. An earlier version + # of this instrumentation checked "$_.Exception -is [COMException]" directly, + # which is always False for a bare catch on a COM failure - verified locally + # against a compiled method that throws a genuine COMException. That silently + # disabled the retry (threw on attempt 1 every time) while still logging + # "retried=False", which looks exactly like "never matched the typed catch" - + # the wrong diagnosis for what was actually a policy regression. Check both + # the exception itself and its InnerException. + # + # Also retry a genuine ElementNotAvailableException (HRESULT 0x80040201), + # confirmed via 473b64b's own CI run: "windows-spikes" logged + # "UIA_GETCHILDREN_RETRY ... hresult=0x80040201 retried=False" under heavy + # concurrent load (multiple pwsh/conhost sessions live at once per the + # PRODUCT_RESOURCE_METRICS_JSON snapshots in that run), then threw + # uncaught. Verified locally by constructing the real exception from + # UIAutomationTypes.dll: HResult 0x80040201 matches exactly, and it derives + # from SystemException directly, not COMException - so neither the old + # typed catch nor the fixed COMException check could ever have retried it. + # This is NOT the be1497d swallow reinstated: that bug returned @() after + # exhausting its budget, making every "-eq 0"/"-not" assertion built on a + # dead parent pass vacuously. This still always throws on exhaustion or on + # any other exception type - it only widens which transient, recoverable + # exception types get a bounded, wall-clock-limited chance to resolve + # before that unconditional throw. + $inner = $_.Exception.InnerException + $isRetryable = ($_.Exception -is [System.Runtime.InteropServices.COMException]) -or + ($inner -is [System.Runtime.InteropServices.COMException]) -or + ($_.Exception -is [System.Windows.Automation.ElementNotAvailableException]) -or + ($inner -is [System.Windows.Automation.ElementNotAvailableException]) + $hresult = if ($inner) { $inner.HResult } else { $_.Exception.HResult } + $innerType = if ($inner) { $inner.GetType().FullName } else { "" } + $attempt++ + $elapsedMs = [int](5000 - ($deadline - (Get-Date)).TotalMilliseconds) + # Includes a freshly-refreshed $process.MainWindowHandle (script-scope, + # already read this way by Wait-ForPopupMenu et al.) as the counterpart + # to Wait-ForRootReconnect's UIA_ROOT_RECONNECT_OK handle log, to check + # whether the window in play differs between a preceding reconnect and + # this failing read. This file has 18 separate $process.Refresh() call + # sites, so without an explicit Refresh() immediately before this read, + # the value returned here depends on whichever unrelated site last + # refreshed it rather than on the state at this call - Refresh() here + # makes the read deterministic. + # + # NOTE: this file runs two separate shell processes at different + # points ($process for the main gate, $settingsProcess for the + # Product Settings fixture near the end). Get-DirectChildren picks up + # $process by dynamic scope regardless of which window's tree is + # actually being walked, so a handle logged while walking the + # settings window's tree is $process's handle, not $settingsProcess's + # - it does not identify the window in play in that phase. + if ($process) { $process.Refresh() } + $handleText = if ($process) { $process.MainWindowHandle } else { "" } + # Call-site marker: identifies which of this function's ~35 call + # sites is failing without instrumenting each one individually. CI has + # shown failures with several seconds of silence beforehand (no "==>" + # step marker in between), so without this a failure elapsedMs/attempt + # count alone cannot be mapped back to a specific gate step. + $callSite = (Get-PSCallStack | Select-Object -Skip 1 -First 4 | + ForEach-Object { "$($_.FunctionName):$($_.ScriptLineNumber)" }) -join "<-" + Write-Host "UIA_GETCHILDREN_RETRY attempt=$attempt elapsedMs=$elapsedMs type=$($_.Exception.GetType().FullName) innerType=$innerType hresult=0x$($hresult.ToString('X8')) retried=$isRetryable handle=$handleText site=$callSite message=$($_.Exception.Message)" + # Budget is wall-clock, not attempt count: this call runs inside every tree + # walk across ~35 call sites, and a fixed attempt count multiplied across + # that many sites is exactly the arithmetic that produced the 60-minute CI + # hang earlier on this branch (be1497d). A duration cap keeps the worst-case + # cost per call bounded regardless of how many sites hit it. + if ((-not $isRetryable) -or ((Get-Date) -ge $deadline)) { + # Diagnostic only, on the way to an unconditional rethrow: distinguish + # crashed (HasExited true) from window-destroyed (process alive, zero + # top-level windows) from window-hidden (a window exists but is not + # visible) from handle-churn (a visible window exists under a handle + # MainWindowHandle no longer reports) - four different bugs that are + # otherwise indistinguishable from this exception alone. Best-effort: + # swallow any failure describing process state so the real exception + # is still the one that propagates. + $diagnosticHasExited = $false + $diagnosticExitCode = $null + try { + if ($process) { + $process.Refresh() + $diagnosticHasExited = $process.HasExited + if ($diagnosticHasExited) { + try { + $diagnosticExitCode = $process.ExitCode + } catch { + $diagnosticExitCode = $null + } + } + $exitDetail = if ($diagnosticHasExited) { + "true exitCode=$(if ($null -eq $diagnosticExitCode) { 'unavailable' } else { $diagnosticExitCode })" + } else { + "false" + } + $windows = [GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) + $windowsText = if ($windows.Count -gt 0) { $windows -join ";" } else { "(none)" } + Write-Host "UIA_GETCHILDREN_EXHAUSTED hasExited=$exitDetail topLevelWindows=$windowsText" + } + } catch { + Write-Host "UIA_GETCHILDREN_EXHAUSTED process state unavailable: $($_.Exception.Message)" + } + if ($diagnosticHasExited) { + try { + if ($shellErrorPath -and (Test-Path -LiteralPath $shellErrorPath)) { + Write-Host "UIA_GETCHILDREN_SHELL_STDERR_BEGIN" + Get-Content -LiteralPath $shellErrorPath | Write-Host + Write-Host "UIA_GETCHILDREN_SHELL_STDERR_END" + } + } catch { + Write-Host "UIA_GETCHILDREN_SHELL_STDERR unavailable: $($_.Exception.Message)" + } + } + throw + } + Start-Sleep -Milliseconds 150 + } } - return @($children.ToArray()) } function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { @@ -744,6 +947,7 @@ function Find-FragmentById( [string] $automationId, [System.Windows.Automation.TreeWalker] $walker ) { + if ($null -eq $root) { return $null } $pending = New-Object System.Collections.Generic.Queue[System.Windows.Automation.AutomationElement] $pending.Enqueue($root) while ($pending.Count -gt 0) { @@ -789,6 +993,138 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed } } +# 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 +# Get-DirectChildren's bounded COM/ENA retry can recover from. CI evidence for +# this: a walk that failed mid-enumeration with "Catastrophic failure +# (E_UNEXPECTED)" on GetNextSibling, then failed every subsequent attempt with +# ElementNotAvailableException on GetFirstChild for the rest of a 5-second +# retry budget - a corpse observed at two stages, not a glitch that recovers. +# Retrying the same stale reference can never revive it; only re-acquiring +# graphcode-root the same way it was first acquired can. +# +# Re-resolve $root via FromHandle on the shell's main window handle (checking +# AutomationId, exactly like the initial acquisition loop), then prove a tree +# walk of it succeeds *in every view the caller is about to use* before +# returning it as live. +# +# What was observed, not why (mechanism is not established): a first version +# of this helper verified only with the raw-view walker. On one CI run, that +# walk succeeded (reconnect returned without throwing) and the very next +# statement's control-view walk then failed with ElementNotAvailableException +# for the entire 5s budget, never recovering. On a later CI run, after adding +# a control-view check here too, reconnect again returned success with zero +# retries logged (both views walked cleanly on the first attempt) - and the +# very next Get-DirectChildren call still failed immediately (5ms later) and +# stayed dead for the full budget. That second result does not fit "the two +# views settle at different times": both were proven walkable moments before +# the failure. It is equally consistent with the element dying in the +# window between this check and its use, i.e. the teardown had not actually +# finished when the check passed. Do not treat either explanation as +# confirmed; the fix below (require every view the caller is about to use to +# be walkable) is defensible under both, so it stays regardless of which one +# is eventually shown to be correct. +# +# Waits on that precondition only - it does not touch any caller assertion. +# On exhaustion, Require fails with HasExited and the last exception observed, +# so a genuine product crash (the shell actually died) is distinguishable +# from a gate-side reconnection failure. Logs the reconnected window handle +# on success as cheap insurance: if a later failure at this site is ever +# paired with a handle-logging point downstream, a differing handle would +# prove the time-of-check/time-of-use explanation outright rather than +# requiring another diagnostic round-trip. +function Wait-ForRootReconnect( + [System.Diagnostics.Process] $process, + [System.Windows.Automation.TreeWalker[]] $walkers, + [int] $maxAttempts = 40, + [int] $delayMs = 250 +) { + $reconnected = $null + $lastException = $null + for ($attempt = 0; $attempt -lt $maxAttempts; $attempt++) { + $process.Refresh() + if ($process.HasExited) { throw "shell exited with code $($process.ExitCode) while reconnecting graphcode-root" } + if ($process.MainWindowHandle -ne 0) { + try { + $candidate = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle) + if ($candidate.Current.AutomationId -eq "graphcode-root") { + foreach ($walker in $walkers) { $null = @($walker.GetFirstChild($candidate)) } + $reconnected = $candidate + Write-Host "UIA_ROOT_RECONNECT_OK attempt=$attempt handle=$($process.MainWindowHandle)" + break + } + } catch { + $lastException = $_ + Write-Host "UIA_ROOT_RECONNECT_RETRY attempt=$attempt type=$($_.Exception.GetType().FullName) message=$($_.Exception.Message)" + } + } + Start-Sleep -Milliseconds $delayMs + } + $failureDetail = if ($null -ne $lastException) { + " (last: $($lastException.Exception.GetType().FullName): $($lastException.Exception.Message))" + } else { "" } + Require ($null -ne $reconnected) "graphcode-root did not become reachable after modal teardown$failureDetail" + return $reconnected +} + +# A surface transition (navigating destinations, opening/closing a native +# HWND-hosted inspection view) can leave the accessibility tree in a brief, +# genuinely-transient state where a fragment that is about to exist (or that +# briefly disappeared mid-rebuild) isn't found by a single BFS pass. Retry the +# whole search a bounded number of times before treating it as truly absent. +function Find-FragmentByIdWithRetry( + [System.Windows.Automation.AutomationElement] $root, + [string] $automationId, + [System.Windows.Automation.TreeWalker] $walker, + [int] $maxAttempts = 20 +) { + for ($attempt = 0; $attempt -lt $maxAttempts; $attempt++) { + $found = Find-FragmentById $root $automationId $walker + if ($null -ne $found) { return $found } + Start-Sleep -Milliseconds 150 + } + return $null +} + +# Captures a small region of the real rendered desktop (screen coordinates) around +# a point and reports whether any sampled pixel is within `tolerance` of `expected` +# in each RGB channel. Used as genuine visual evidence for canvas painting (hover +# handles, grid lines) that has no dedicated UIA element to query. +function Test-ScreenPixelNear( + [int] $screenX, + [int] $screenY, + [System.Drawing.Color] $expected, + [int] $radius = 6, + [int] $tolerance = 24 +) { + $left = $screenX - $radius + $top = $screenY - $radius + $size = New-Object System.Drawing.Size(($radius * 2 + 1), ($radius * 2 + 1)) + $bitmap = New-Object System.Drawing.Bitmap($size.Width, $size.Height) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($left, $top, 0, 0, $size) + } finally { + $graphics.Dispose() + } + for ($x = 0; $x -lt $size.Width; $x++) { + for ($y = 0; $y -lt $size.Height; $y++) { + $pixel = $bitmap.GetPixel($x, $y) + if (([Math]::Abs([int]$pixel.R - [int]$expected.R) -le $tolerance) -and + ([Math]::Abs([int]$pixel.G - [int]$expected.G) -le $tolerance) -and + ([Math]::Abs([int]$pixel.B - [int]$expected.B) -le $tolerance)) { + return $true + } + } + } + return $false + } finally { + $bitmap.Dispose() + } +} + function Assert-FragmentLinks( [System.Windows.Automation.AutomationElement] $parent, [System.Windows.Automation.TreeWalker] $walker, @@ -908,10 +1244,13 @@ try { $policyDirectoryExisted = Test-Path -LiteralPath $policyDirectory $policyExisted = Test-Path -LiteralPath $policyPath if ($policyExisted) { $policyContents = [IO.File]::ReadAllBytes($policyPath) } + $shellErrorPath = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-shell-stderr-$PID.log" if ($ArgumentList.Count -gt 0) { - $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal + $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal ` + -RedirectStandardError $shellErrorPath } else { - $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal + $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` + -RedirectStandardError $shellErrorPath } $root = $null @@ -933,6 +1272,39 @@ try { $rawWalker = [System.Windows.Automation.TreeWalker]::RawViewWalker $controlWalker = [System.Windows.Automation.TreeWalker]::ControlViewWalker $shellWindow = $process.MainWindowHandle + + # The freshly-launched shell's UI Automation provider can still be settling + # immediately after graphcode-root first responds to WM_GETOBJECT: a tree walk in + # this window can throw ("Catastrophic failure (E_UNEXPECTED)" and separately + # "Unrecognized error" have both been observed on CI, ~11s into the gate, before + # any assertion runs) even though Get-DirectChildren's own bounded per-call retry + # (4 attempts, ~600ms) is exhausted before the provider settles. Rather than + # growing that per-call budget - which is paid on every one of the ~35 + # Find-FragmentById call sites for the rest of the run - absorb the one-time + # startup race here, once, with a longer budget before any real assertion begins. + # Catch broadly (not just COMException) and log the concrete exception type/HRESULT + # on each failed attempt: two different error messages have already been observed + # for what looks like the same race, so this is diagnostic evidence for next time + # rather than an assumption about which exception type will show up. + $providerSettled = $false + $lastSettleException = $null + for ($settleAttempt = 0; $settleAttempt -lt 60; $settleAttempt++) { + try { + $null = @($rawWalker.GetFirstChild($root)) + $providerSettled = $true + break + } catch { + $lastSettleException = $_ + $hresult = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } + Write-Host "UIA_PROVIDER_SETTLE_RETRY attempt=$settleAttempt type=$($_.Exception.GetType().FullName) hresult=0x$($hresult.ToString('X8')) message=$($_.Exception.Message)" + Start-Sleep -Milliseconds 250 + } + } + $settleFailureDetail = if ($null -ne $lastSettleException) { + " (last: $($lastSettleException.Exception.GetType().FullName): $($lastSettleException.Exception.Message))" + } else { "" } + Require $providerSettled "shell UI Automation provider did not settle after graphcode-root appeared$settleFailureDetail" + $desktop = [System.Windows.Automation.AutomationElement]::RootElement $updateDialog = $desktop.FindFirst( [System.Windows.Automation.TreeScope]::Descendants, @@ -977,7 +1349,15 @@ try { "update offer did not expose Release Notes and Later actions" Require ([GraphCodeUiaGateState]::SendCommand([IntPtr]$updateDialog.Current.NativeWindowHandle, 9703)) ` "update offer Later action could not be invoked" - Start-Sleep -Milliseconds 150 + # Dismissing the modal rebuilds the shell's fragment tree; $root captured + # before this point can be a stale reference that Get-DirectChildren's + # bounded COM/ENA retry cannot revive (see Wait-ForRootReconnect). Other + # modal teardowns later in this file (SendCommand 9703 again ~line 1265, + # and ~line 3124) have the identical latent exposure but are out of scope + # for this fix - noted here rather than swept up in one change. Verify with + # both walkers this site is about to use (status lookup below is + # ControlView; the root-children assertions further down use both views). + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) $status = Find-FragmentById $root "status" $controlWalker $rawRootChildren = @(Assert-FragmentLinks $root $rawWalker $expectedRootIds "RawView root") $controlRootChildren = @(Assert-FragmentLinks $root $controlWalker $expectedRootIds "ControlView root") @@ -1462,6 +1842,59 @@ try { "Graph exposed unexpected or missing children: $($graphChildIds -join ',')" $null = Assert-FragmentLinks $graph $rawWalker $graphChildIds "RawView Graph" $null = Assert-FragmentLinks $graph $controlWalker $graphChildIds "ControlView Graph" + + # Canvas attention rail: GraphCanvas.hitTestAttentionRail covers a full-width band + # with no dedicated UIA element of its own (only the per-card "Reply" action above + # is exposed to UIA). Its client rect is + # (sidebar_width+20, header_height+12, width-20, header_height+43); since the graph + # fragment's own bounds already start at client (sidebar_width, header_height) and + # extend to client (width, ...), that reduces to the screen rect + # (graph.Left+20, graph.Top+12, graph.Right-20, graph.Top+43) with no sidebar_width + # or header_height constants needed. This posts a real WM_LBUTTONDOWN+UP inside + # that rect and verifies the resulting selection change through the same + # SelectionItemPattern already exercised for the loop cards, exercising the exact + # App.zig .review_attention -> selectNextAttention() routing a physical mouse click + # on the rail would drive. + # Refresh the cached top-level window handle immediately before issuing any raw + # PostMessage-based mouse synthesis below: $shellWindow was captured once right + # after launch (line ~902) via $process.MainWindowHandle, which .NET does not + # auto-refresh, and by this point in the gate the shell has been through several + # dialog open/close and surface-switch round-trips. Re-resolving it here (rather + # than trusting the long-stale value) is required for PostMessage to reach the + # window that is actually currently on screen. + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $attentionSelection0 = $projectCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $attentionSelection1 = $projectCards[1].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $attentionSelection0.Select() + Start-Sleep -Milliseconds 150 + Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` + "could not establish a deterministic starting selection before the attention rail check" + $railScreenX = [int](($graph.Current.BoundingRectangle.Left + $graph.Current.BoundingRectangle.Right) / 2) + $railScreenY = [int]$graph.Current.BoundingRectangle.Top + 27 + $railClientX = 0 + $railClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $railScreenX, $railScreenY, [ref]$railClientX, [ref]$railClientY + )) "could not map the attention rail to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-attention-rail-click" + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0201, $railClientX, $railClientY)) ` + "attention rail click was rejected" + [GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0202, $railClientX, $railClientY) | Out-Null + for ($attempt = 0; $attempt -lt 40 -and (-not $attentionSelection1.Current.IsSelected); $attempt++) { + Start-Sleep -Milliseconds 100 + } + Require ($attentionSelection1.Current.IsSelected -and (-not $attentionSelection0.Current.IsSelected)) ` + "attention rail click did not cycle selection onto the NEEDS YOU card" + + # Restore the deterministic starting selection consumed by later gate steps below + # (this block only needed to prove the rail cycles selection; it must not leak a + # different selection into subsequent, pre-existing assertions). + $attentionSelection0.Select() + Start-Sleep -Milliseconds 150 + Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` + "could not restore starting selection after the attention rail check" + $projectCards[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() $compositeProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' } ` @@ -1488,6 +1921,104 @@ try { Require (($restoredProjectCards.Count -eq 2) -and ((@($restoredProjectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` "Composite Back did not restore the parent project canvas" + + # Connector handles: GraphCanvas.drawNode paints a highlighted (0x00FFCD7A COLORREF + # -> RGB 0x7ACDFF, light blue) hover handle at the outgoing connector position + # (card.Right, card's vertical midpoint) with no dedicated UIA element, and + # drag-to-connect is driven entirely by raw WM_LBUTTONDOWN/WM_MOUSEMOVE/ + # WM_LBUTTONUP messages in App.zig (hitTestConnector on down, updateEdgeDrag on + # move, hitTest + createEdgeBetweenIDs on up). This derives both the connector and + # the target card's screen positions from the already UIA-exposed card bounds, + # synthesizes real pointer messages for both the hover and the full drag, and + # treats the resulting native "Create or edit edge" dialog's locked From/To fields + # as the ground truth evidence that the drop routed to the correct source/target + # loop IDs. + $connectorSourceCard = @($restoredProjectCards | Where-Object { $_.Current.Name -eq "UIA loop A" })[0] + $connectorTargetCard = @($restoredProjectCards | Where-Object { $_.Current.Name -eq "UIA loop B" })[0] + Require (($null -ne $connectorSourceCard) -and ($null -ne $connectorTargetCard)) ` + "missing project cards before the connector handle check" + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $connectorScreenX = [int]$connectorSourceCard.Current.BoundingRectangle.Right + $connectorScreenY = [int](($connectorSourceCard.Current.BoundingRectangle.Top + $connectorSourceCard.Current.BoundingRectangle.Bottom) / 2) + $connectorClientX = 0 + $connectorClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $connectorScreenX, $connectorScreenY, [ref]$connectorClientX, [ref]$connectorClientY + )) "could not map the source loop's outgoing connector to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-connector-hover" + $expectedConnectorHoverColor = [System.Drawing.Color]::FromArgb(0x7A, 0xCD, 0xFF) + $connectorHoverObserved = $false + for ($attempt = 0; $attempt -lt 20 -and (-not $connectorHoverObserved); $attempt++) { + Require ([GraphCodeUiaGateState]::PostMouseMoveAt($shellWindow, $connectorClientX, $connectorClientY)) ` + "connector hover mouse-move message was rejected" + Start-Sleep -Milliseconds 100 + $connectorHoverObserved = Test-ScreenPixelNear -screenX $connectorScreenX -screenY $connectorScreenY -expected $expectedConnectorHoverColor + } + Require $connectorHoverObserved ` + "hovering the outgoing connector did not paint the hover connector handle at (${connectorScreenX},${connectorScreenY})" + $connectorTargetScreenX = [int](($connectorTargetCard.Current.BoundingRectangle.Left + $connectorTargetCard.Current.BoundingRectangle.Right) / 2) + $connectorTargetScreenY = [int](($connectorTargetCard.Current.BoundingRectangle.Top + $connectorTargetCard.Current.BoundingRectangle.Bottom) / 2) + $connectorTargetClientX = 0 + $connectorTargetClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $connectorTargetScreenX, $connectorTargetScreenY, [ref]$connectorTargetClientX, [ref]$connectorTargetClientY + )) "could not map the target loop card body to client coordinates" + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0201, $connectorClientX, $connectorClientY)) ` + "connector drag mouse-down message was rejected" + Require ([GraphCodeUiaGateState]::PostMouseMoveAt($shellWindow, $connectorTargetClientX, $connectorTargetClientY)) ` + "connector drag mouse-move message was rejected" + Start-Sleep -Milliseconds 100 + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0202, $connectorTargetClientX, $connectorTargetClientY)) ` + "connector drag mouse-up message was rejected" + $edgeDialogCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit edge" + )) + ) + $edgeDialog = Wait-ForDesktopElement ` + -desktop $desktop ` + -condition $edgeDialogCondition ` + -label "connector drag Create or edit edge dialog" ` + -diagnosticWindow $shellWindow ` + -RecoverForeground + Require ($null -ne $edgeDialog) "dragging from the connector to the target card did not open the Create or edit edge dialog" + # NativeForms.zig's edge dialog renders locked From/To endpoints as ES_READONLY + # Edit controls, but this app's custom UIA provider (AccessibilityProvider.cpp) + # exposes native dialog fields generically as ControlType.Pane elements carrying + # their text in the Name property (automationId 9100/9101 for From/To) rather than + # bridging them as ControlType.Edit with a ValuePattern. + $edgeFromElement = $edgeDialog.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, "9100" + )) + ) + $edgeToElement = $edgeDialog.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, "9101" + )) + ) + Require (($null -ne $edgeFromElement) -and ($null -ne $edgeToElement)) ` + "Create or edit edge dialog did not expose its locked From/To fields" + $edgeFromValue = $edgeFromElement.Current.Name + $edgeToValue = $edgeToElement.Current.Name + Require ($edgeFromValue -eq "11111111-1111-4111-8111-111111111111") ` + "connector drag did not lock the From endpoint to the dragged source loop: $edgeFromValue" + Require ($edgeToValue -eq "22222222-2222-4222-8222-222222222222") ` + "connector drag did not lock the To endpoint to the dropped target loop: $edgeToValue" + Require ([GraphCodeUiaGateState]::PostClose([IntPtr]$edgeDialog.Current.NativeWindowHandle)) ` + "Create or edit edge dialog rejected cancellation" + Require (Wait-ForDesktopElementGone ` + -desktop $desktop ` + -condition $edgeDialogCondition ` + -label "connector drag Create or edit edge dialog close" ` + -diagnosticWindow $shellWindow) "Create or edit edge dialog did not close after cancellation" + $surfaceActionPatterns = @{} foreach ($id in @($navigationIds + $canvasActionIds)) { $element = Find-FragmentById $root $id $rawWalker @@ -1505,11 +2036,118 @@ try { $overviewCards = @($overviewProbe.Items) Require (($overviewCards.Count -eq 2) -and ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Overview did not expose synchronized cards" + + # Folder lanes/bands: the lane's Open and Worktrees actions have no dedicated UIA + # elements (GraphCanvas.overviewLaneActionAt is a pure hit test painted by GDI), so + # this derives their real screen position from GraphCanvas.overviewLaneBounds' + # actual formula (lane.left = graph.Left + 24, lane width = max(760, + # graph.Width - 48)) and posts real WM_LBUTTONDOWN/UP client-coordinate clicks at + # those points, exercising the exact App.zig click routing a physical mouse would + # drive. A genuine surface transition (not merely re-invoking the same overview + # paint) is proven by the card's BoundingRectangle.Top moving away from the + # lane-grid position ($laneGridCardTop, captured immediately before the click) to + # the free-form project-canvas layout. + # + # An earlier version of this block instead tried to force the shell window wide + # enough that graph.Width - 48 would exceed the 760 floor, first via SW_MAXIMIZE, + # then via a growing SetWindowPos request -- but on a CI runner with a small + # virtual desktop, requesting an ever-larger window rect does not produce an + # ever-larger client rect: repeated attempts up to a 6000x2687 request all landed + # at the same ~1028px client width (confirmed by GetClientRect in that run), + # because the OS-level max-track-size clamp is bound to the monitor's real work + # area, not to whatever this script asks for. That made a "-gt 808" width + # precondition unsatisfiable on that runner no matter how the resize was framed, + # and chasing it further would have been fighting a fixed environment constraint + # instead of fixing the test. The lane-width formula's floor case is exactly as + # real a code path as its non-floor case, so this now computes lane.right + # correctly for whichever branch the shell's actual (possibly narrow) canvas + # falls into, using the graph element's own live BoundingRectangle -- valid at + # any window size and requiring no resize at all. actual-size is invoked first so + # CanvasState.zoom/pan_x/pan_y (state that participates in the same + # transformedRect() call the lane, card, and button rects all go through) are + # reset to the identity transform (1, 0, 0) that the arithmetic below assumes; + # without that, a zoom/pan left over from an earlier gate step could shift every + # screen coordinate computed here. + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $surfaceActionPatterns["actual-size"].Invoke() + Start-Sleep -Milliseconds 150 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $overviewCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require (($overviewCards.Count -eq 2) -and + ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` + "Overview did not expose synchronized cards after resetting zoom/pan to actual size" + $graphBounds = $graph.Current.BoundingRectangle + $laneWidth = [Math]::Max(760, [int]$graphBounds.Width - 48) + $laneRight = [int]$graphBounds.Left + 24 + $laneWidth + $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top + $laneOpenScreenX = $laneRight - 104 + $laneOpenScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 + $laneOpenClientX = 0 + $laneOpenClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $laneOpenScreenX, $laneOpenScreenY, [ref]$laneOpenClientX, [ref]$laneOpenClientY + )) "could not map the overview lane Open action to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-lane-open-click" + Require ([GraphCodeUiaGateState]::PostMouseClickAt($shellWindow, $laneOpenClientX, $laneOpenClientY)) ` + "overview lane Open click was rejected" + for ($attempt = 0; $attempt -lt 40; $attempt++) { + Start-Sleep -Milliseconds 100 + $laneOpenedCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + if (($laneOpenedCards.Count -eq 2) -and + ($laneOpenedCards[0].Current.BoundingRectangle.Top -ne $laneGridCardTop)) { break } + } + $laneOpenSucceeded = ($laneOpenedCards.Count -eq 2) -and + ((@($laneOpenedCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and + ($laneOpenedCards[0].Current.BoundingRectangle.Top -ne $laneGridCardTop) + Require $laneOpenSucceeded "overview lane Open click did not route to the project canvas layout" + Start-Sleep -Milliseconds 200 + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 250 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment after returning from the project canvas layout" + $overviewCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require ($overviewCards.Count -eq 2) "overview did not restore synchronized cards before the Worktrees lane check" + $laneWorktreesScreenX = [int]$graph.Current.BoundingRectangle.Right - 69 + $laneWorktreesScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 + $worktrees = Find-FragmentById $root "worktrees" $rawWalker + Require ($null -ne $worktrees) "missing Worktrees fragment before the overview lane Worktrees check" + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $laneWorktreesClientX = 0 + $laneWorktreesClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, $laneWorktreesScreenX, $laneWorktreesScreenY, [ref]$laneWorktreesClientX, [ref]$laneWorktreesClientY + )) "could not map the overview lane Worktrees action to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-lane-worktrees-click" + Require ([GraphCodeUiaGateState]::PostMouseClickAt($shellWindow, $laneWorktreesClientX, $laneWorktreesClientY)) ` + "overview lane Worktrees click was rejected" + $laneWorktreeRows = @() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + Start-Sleep -Milliseconds 100 + $laneWorktreeRows = @(Get-DirectChildren $worktrees $rawWalker | Where-Object { + $_.Current.AutomationId -match '^worktree-row-' + }) + if ($laneWorktreeRows.Count -gt 0) { break } + } + Require ($laneWorktreeRows.Count -gt 0) "overview lane Worktrees click did not open worktree inspection" + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 500 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment after returning from worktree inspection" + $surfaceActionPatterns["quick-chats-destination"].Invoke() $quickChatProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA chat ' } ` { param($items) $items.Count -eq 2 } $graph = $quickChatProbe.Graph + Require ($null -ne $graph) "missing graph fragment after switching to the Quick Chats destination" $quickChatCards = @($quickChatProbe.Items) Require (($quickChatCards.Count -eq 2) -and ((@($quickChatCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA chat A|UIA chat B")) "Quick Chats did not expose synchronized cards: $(@($quickChatCards | ForEach-Object { $_.Current.Name }) -join '|')" @@ -1560,15 +2198,34 @@ try { Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Creating quick chat...") ` "Quick Chats New Chat action did not execute" $quickChatCardIds = @($quickChatCards | ForEach-Object { $_.Current.AutomationId }) - $quickChatCards[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() - Start-Sleep -Milliseconds 150 - Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Opening quick chat...") ` - "Quick Chat invocation did not perform its expected action" - $graph = Find-FragmentById $root "graph" $rawWalker - $quickChatWorkspace = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^quick-chat-workspace-' -and - $_.Current.Name -eq "Quick Chat terminal workspace" - }) | Select-Object -First 1 + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph) "missing graph fragment before invoking a Quick Chat card" + $refreshedQuickChatCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -eq $quickChatCardIds[0] + }) + Require ($refreshedQuickChatCards.Count -eq 1) "Quick Chat card disappeared after the New Chat action" + $refreshedQuickChatCards[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $sawOpeningStatus = $false + for ($attempt = 0; $attempt -lt 150; $attempt++) { + if ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Opening quick chat...") { + $sawOpeningStatus = $true + break + } + Start-Sleep -Milliseconds 20 + } + Require $sawOpeningStatus "Quick Chat invocation did not perform its expected action" + $quickChatWorkspace = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $quickChatWorkspace = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-workspace-' -and + $_.Current.Name -eq "Quick Chat terminal workspace" + }) | Select-Object -First 1 + if (($null -ne $quickChatWorkspace) -and + ($quickChatWorkspace.Current.BoundingRectangle.Width -gt 0) -and + ($quickChatWorkspace.Current.BoundingRectangle.Height -gt 0)) { break } + Start-Sleep -Milliseconds 50 + } Require ($null -ne $quickChatWorkspace) "Quick Chat invocation did not expose its terminal workspace" Require (($quickChatWorkspace.Current.BoundingRectangle.Width -gt 0) -and ($quickChatWorkspace.Current.BoundingRectangle.Height -gt 0)) ` @@ -1579,6 +2236,11 @@ try { $surfaceActionPatterns["fit-canvas"].Invoke() Start-Sleep -Milliseconds 250 $process.Refresh() + if ($process.HasExited) { + if (Test-Path -LiteralPath $shellErrorPath) { + Get-Content -LiteralPath $shellErrorPath | Write-Host + } + } Require (-not $process.HasExited) "surface UIA actions terminated the shell" $activeProjectRow = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^open-project-' -and $_.Current.Name -eq "UIA project" @@ -1592,10 +2254,39 @@ try { $activeLoopRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 250 $process.Refresh() - Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" - $workspaceCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' - }) + $dynamicInvocationHasExited = $process.HasExited + $dynamicInvocationExitCode = $null + if ($dynamicInvocationHasExited) { + try { + $dynamicInvocationExitCode = $process.ExitCode + } catch { + $dynamicInvocationExitCode = $null + } + if (Test-Path -LiteralPath $shellErrorPath) { + Get-Content -LiteralPath $shellErrorPath | Write-Host + } + } + $dynamicInvocationExitText = if ($null -eq $dynamicInvocationExitCode) { "unavailable" } else { $dynamicInvocationExitCode } + Require (-not $dynamicInvocationHasExited) "dynamic project or loop invocation crashed the shell (exit $dynamicInvocationExitText)" + # This dynamic project/loop invocation was observed on a loaded CI runner both + # exhausting Find-FragmentByIdWithRetry's default 20-attempt/3s budget re-fetching + # "graph" itself, and - separately - remounting graph's "canvas-card-" children a + # moment after graph reappears (the same remount race Wait-ForGraphChildren exists + # for elsewhere in this file; #440 needed 10s->20s for the analogous workspace + # chrome children on this same fragment). A single-shot Get-DirectChildren read + # right after resolving graph could observe an empty or partial set from either + # race. Wait-ForGraphChildren re-resolves graph itself on every attempt, so one + # call covers both: it waits on the presence precondition only (both cards + # exist), never on the full assertion. -maxAttempts 150 (~15s) preserves the + # extra headroom the graph-refetch race needed, now covering the children-mount + # race too. The full identity/ordering/selection assertion below is unchanged. + $workspaceProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' } ` + { param($items) $items.Count -eq 2 } ` + -maxAttempts 150 + $graph = $workspaceProbe.Graph + Require ($null -ne $graph) "missing graph fragment after dynamic project/loop invocation" + $workspaceCards = @($workspaceProbe.Items) Require (($workspaceCards.Count -eq 2) -and ((@($workspaceCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index eb336e86..c2ad180e 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -27,6 +27,7 @@ extern fn gc_uia_create(hwnd: c.HWND) ?*NativeProvider; extern fn gc_uia_release(provider: *NativeProvider) void; extern fn gc_uia_get_object(hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM, provider: *NativeProvider) c.LRESULT; extern fn gc_uia_set_status(provider: *NativeProvider, status: [*:0]const u8) c.HRESULT; +extern fn gc_uia_set_canvas_bounds(provider: *NativeProvider, left: c_int, top: c_int, right: c_int, bottom: c_int) c.HRESULT; extern fn gc_uia_update( provider: *NativeProvider, status: [*:0]const u8, @@ -246,6 +247,15 @@ pub const Provider = struct { defer self.allocator.free(status_z); _ = gc_uia_set_status(native, status_z.ptr); } + /// Reports the real, current client-relative rect of the rendered canvas + /// so the "graph" fixed UIA element (id 4) exposes accurate + /// BoundingRectangle geometry for automation and testing, instead of a + /// disconnected placeholder rect. + pub fn syncCanvasBounds(self: *Provider, bounds: c.RECT) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + _ = gc_uia_set_canvas_bounds(native, bounds.left, bounds.top, bounds.right, bounds.bottom); + } pub fn add(self: *Provider, element: Element) !usize { const index = self.elements.items.len; try self.elements.append(element); diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index f25aa558..4effc613 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,11 @@ struct State { bool allow_reclaim = false; bool confirm_each_reclaim = true; bool active = true; + // Real, current client-relative rect of the rendered canvas ("graph" fixed + // element, id_ == 4). Populated by gc_uia_set_canvas_bounds; until the app + // reports one, get_BoundingRectangle falls back to a placeholder rect. + RECT canvas_bounds{}; + bool has_canvas_bounds = false; }; static std::wstring wide(const char *value) { @@ -275,6 +281,8 @@ class Node final : public IRawElementProviderSimple, HWND hwnd = nullptr; RECT dynamic_bounds{}; bool has_dynamic_bounds = false; + RECT canvas_bounds{}; + bool has_canvas_bounds = false; { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; @@ -282,6 +290,9 @@ class Node final : public IRawElementProviderSimple, if (isRowKey(id_)) { dynamic_bounds = state_->rows.at(id_).bounds; has_dynamic_bounds = true; + } else if (id_ == 4 && state_->has_canvas_bounds) { + canvas_bounds = state_->canvas_bounds; + has_canvas_bounds = true; } } RECT rect{}; @@ -297,6 +308,14 @@ class Node final : public IRawElementProviderSimple, value->top = origin.y + dynamic_bounds.top; value->width = dynamic_bounds.right - dynamic_bounds.left; value->height = dynamic_bounds.bottom - dynamic_bounds.top; + } else if (has_canvas_bounds) { + // The "graph" fixed element (id_ == 4) reflects the real, current + // rendered canvas rect once the app has reported one, instead of the + // disconnected placeholder rect used before any report arrives. + value->left = origin.x + canvas_bounds.left; + value->top = origin.y + canvas_bounds.top; + value->width = canvas_bounds.right - canvas_bounds.left; + value->height = canvas_bounds.bottom - canvas_bounds.top; } else if (id_ == 14 || id_ == 15) { value->left = origin.x + 8; value->top = origin.y + (id_ == 14 ? 142 : 358); @@ -629,6 +648,24 @@ class Node final : public IRawElementProviderSimple, } raiseStatusChanged(status_node, old_status, new_status); } + void setCanvasBounds(int left, int top, int right, int bottom) { + Node *graph_node = nullptr; + bool changed = false; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + const RECT next{left, top, right, bottom}; + changed = !state_->has_canvas_bounds || memcmp(&state_->canvas_bounds, &next, sizeof(RECT)) != 0; + state_->canvas_bounds = next; + state_->has_canvas_bounds = true; + if (changed) graph_node = retainElementLocked(4); + } + if (graph_node) { + UiaRaiseAutomationPropertyChangedEvent( + graph_node, UIA_BoundingRectanglePropertyId, VARIANT{}, VARIANT{}); + graph_node->Release(); + } + } void shutdown() { if (id_ != 0) return; std::vector elements; @@ -1030,3 +1067,10 @@ extern "C" HRESULT gc_uia_set_status(IRawElementProviderSimple *provider, const static_cast(provider)->setStatus(status); return S_OK; } + +extern "C" HRESULT gc_uia_set_canvas_bounds(IRawElementProviderSimple *provider, int left, int top, + int right, int bottom) { + if (!provider) return E_INVALIDARG; + static_cast(provider)->setCanvasBounds(left, top, right, bottom); + return S_OK; +} diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c9b35b91..e3cf12f3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3535,6 +3535,7 @@ pub const App = struct { }, .cycle_attention => { self.selectNextAttention(); + self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .inspect_worktrees => self.inspectWorktrees(), @@ -3936,6 +3937,7 @@ pub const App = struct { .right = canvas_bounds.right, .bottom = canvas_bounds.bottom, }; + provider.syncCanvasBounds(canvas_rect); var sidebar_rows = Sidebar.appendRows( self.allocator, &self.model, @@ -5608,6 +5610,7 @@ fn onWindowMessage( app.canvas.beginPan(x, y); _ = c.SetCapture(hwnd); } + app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); }, .quick_chats => { diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 81694b3b..87ab3937 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -1889,6 +1889,45 @@ test "overview and quick chat hit testing follows rendered cards" { try std.testing.expect(hitTestQuickChat(3, bounds.left - 1, chat.top, &state, bounds) == null); } +test "cross-project overview stacks every open folder as its own lane" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const frameA = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Loop A1","state":"running"},{"id":"a2","title":"Loop A2","state":"running"}],"edges":[]}}} + ; + const frameB = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"Beta"},"nodes":[{"id":"b1","title":"Loop B1","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frameA); + _ = try model.updateFromFrame(frameB); + try std.testing.expectEqual(@as(usize, 2), model.graphs.items.len); + var state = CanvasState{}; + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800); + const laneA = overviewLaneBounds(&model, 0, bounds, &state); + const laneB = overviewLaneBounds(&model, 1, bounds, &state); + // Every open folder renders as its own lane on the shared canvas: the second + // project's lane must start strictly below the first project's lane (not + // overlap it), by at least that lane's rendered height plus the inter-lane gap. + try std.testing.expect(laneB.top >= laneA.bottom + 20); + try std.testing.expectEqual(laneA.left, laneB.left); + try std.testing.expectEqual(laneA.right, laneB.right); + // Hit testing must resolve a click in the second lane to the second project's + // graph index, proving the lanes are independently addressable, not just + // visually stacked. + const cardB = overviewCardBounds(&model, 1, 0, bounds, &state); + const hit = hitTestOverview(&model, cardB.left + 4, cardB.top + 4, &state, bounds) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 1), hit.graph_index); + try std.testing.expectEqual(@as(usize, 0), hit.node_index); + // Each lane exposes its own Open/Worktrees action targets, independently + // positioned per-lane rather than a single shared control. + const laneAOpen = overviewLaneActionAt(&model, 0, laneA.right - 100, laneA.top + 20, bounds, &state); + const laneBOpen = overviewLaneActionAt(&model, 1, laneB.right - 100, laneB.top + 20, bounds, &state); + try std.testing.expectEqual(OverviewLaneAction.open_project, laneAOpen orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(OverviewLaneAction.open_project, laneBOpen orelse return error.TestUnexpectedResult); +} + test "overview and quick chat geometry applies pan and zoom consistently" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index bb4e62b8..283dd3ac 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -160,6 +160,21 @@ pub fn surfaceIdentityMatches(surface: *const Surface, project_path: []const u8, std.mem.eql(u8, surface.session_name, session); } +fn moveReplacementSurface( + surfaces: *[max_surfaces]Surface, + target_index: usize, + replacement_index: usize, +) void { + std.debug.assert(target_index < surfaces.len); + std.debug.assert(replacement_index < surfaces.len); + std.debug.assert(target_index != replacement_index); + std.debug.assert(surfaces[target_index].surface == null); + std.debug.assert(surfaces[target_index].attach == null); + std.debug.assert(surfaces[replacement_index].surface != null or + surfaces[replacement_index].attach != null); + std.mem.swap(Surface, &surfaces[target_index], &surfaces[replacement_index]); +} + pub const Workspace = struct { parent: c.HWND, host: ?*c.winghostty_host = null, @@ -401,8 +416,7 @@ pub const Workspace = struct { return err; }; self.destroySurface(index); - self.surfaces[index] = self.surfaces[replacement_index]; - self.surfaces[replacement_index] = .{}; + moveReplacementSurface(&self.surfaces, index, replacement_index); self.syncTopology(); self.clearRecreateSession(index); return; @@ -1790,6 +1804,51 @@ test "surface identity cannot leak a session across project paths" { try std.testing.expect(!surfaceIdentityMatches(&second, "C:\\work\\first", "node-1")); } +test "replacement preserves both surface cell buffers for donor reuse" { + var surfaces = [_]Surface{.{}} ** max_surfaces; + defer for (&surfaces) |*surface| { + if (surface.session_name.len != 0) std.testing.allocator.free(surface.session_name); + if (surface.cells.len != 0) std.testing.allocator.free(surface.cells); + }; + surfaces[0].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); + surfaces[1].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count); + + const target_cells = surfaces[0].cells.ptr; + const replacement_cells = surfaces[1].cells.ptr; + const live_surface: *c.winghostty_surface = @ptrFromInt(1); + surfaces[1].surface = live_surface; + surfaces[1].session_name = try std.testing.allocator.dupe(u8, "replacement"); + + moveReplacementSurface(&surfaces, 0, 1); + + try std.testing.expectEqual(live_surface, surfaces[0].surface.?); + try std.testing.expectEqualStrings("replacement", surfaces[0].session_name); + try std.testing.expectEqual(cell_count, surfaces[0].cells.len); + try std.testing.expectEqual(replacement_cells, surfaces[0].cells.ptr); + try std.testing.expectEqual(cell_count, surfaces[1].cells.len); + try std.testing.expectEqual(target_cells, surfaces[1].cells.ptr); + try std.testing.expect(surfaces[0].cells.ptr != surfaces[1].cells.ptr); + + feedCells(&surfaces[1], "A"); + try std.testing.expectEqual(@as(u32, 'A'), surfaces[1].cells[0].codepoint); + + surfaces[0].surface = null; + std.testing.allocator.free(surfaces[0].session_name); + surfaces[0].session_name = &.{}; + const second_surface: *c.winghostty_surface = @ptrFromInt(2); + surfaces[1].surface = second_surface; + surfaces[1].session_name = try std.testing.allocator.dupe(u8, "second replacement"); + + moveReplacementSurface(&surfaces, 0, 1); + + try std.testing.expectEqual(second_surface, surfaces[0].surface.?); + try std.testing.expectEqualStrings("second replacement", surfaces[0].session_name); + try std.testing.expectEqual(target_cells, surfaces[0].cells.ptr); + try std.testing.expectEqual(replacement_cells, surfaces[1].cells.ptr); + feedCells(&surfaces[1], "B"); + try std.testing.expectEqual(@as(u32, 'B'), surfaces[1].cells[0].codepoint); +} + fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace { return Workspace{ .parent = null, diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 53bc78bb..5fbe8f56 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -63,16 +63,16 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Cross-project global graph | Every open folder as a lane on one canvas | Windows renders every loaded graph summary as a lane; the real executable was exercised against the protocol stub and multi-project identity/layout has automated coverage | Partial | -| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes now render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, but a live executable walkthrough remains blocked by the local Windows shell toolchain | Partial | -| Notebook grid | Grid pans and zooms with canvas | GDI grid pans and zooms with the same transform used by project, overview, and Quick Chats content | Partial | -| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas now scale by notch count and have focused regression coverage. Native WM_POINTER/WM_GESTURE pinch evidence remains absent and the live walkthrough is blocked by the local shell toolchain | Partial | -| Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line now accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu, and the live UIA provider exposes invokable controls with bounds. Live re-capture remains blocked by the local shell toolchain | Partial | +| Cross-project global graph | Every open folder as a lane on one canvas | `GraphCanvas.overviewLaneBounds` stacks every open project's lane vertically by accumulated lane height, and a focused test (`GraphCanvas.zig`: "cross-project overview stacks every open folder as its own lane") now proves a second project's lane renders below the first without overlap, is independently hit-testable, and exposes its own per-lane Open/Worktrees targets. The Windows shell toolchain blocker is resolved and the live gate exercises the deterministic single-project overview reliably (see Folder lanes/bands), but the shared UIA fixture still only opens one project, so there is no live capture of two or more simultaneous lanes side by side. Promoting to Validated would require extending the fixture to register a second project before this can move further | Partial | +| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, and the local Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` posts real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` messages at the lane's Open and Worktrees hit-test rects against the live executable and verifies Open routes to the project canvas (synchronized cards render at a new position) and Worktrees opens the scoped inspection view, both confirmed passing across many consecutive live runs. This also uncovered and fixed two real accessibility bugs along the way: `App.zig`'s `.overview` mouse-click switch arm and its `.cycle_attention` action handler were both missing the `syncAccessibility()` call that keeps the live UIA tree in sync with what is rendered, so a lane's Open/Worktrees click previously had no observable effect through the accessibility tree even though the underlying surface did change | Validated | +| Notebook grid | Grid pans and zooms with canvas | `GraphCanvas.drawGrid` derives its cell size and offset from the exact same `CanvasState.zoom`/`pan_x`/`pan_y` fields consumed by `overviewCardBounds`, `overviewLaneBounds`, and the loop-card geometry, so the same focused pan/zoom coverage (`GraphCanvas.zig`: "canvas hit testing follows pan and zoom", "overview and quick chat geometry applies pan and zoom consistently") indirectly proves the grid cannot desynchronize from the content it underlays. The Windows shell toolchain blocker is resolved, but the grid itself is a 1px `0x00161815` GDI line pattern with no UIA surface of its own, and this session did not add a live pixel-scan assertion (the connector-handle and attention-rail blocks already show this pattern is feasible) to directly confirm grid line spacing changes with zoom in the running executable. Left Partial rather than claim live evidence that was not actually captured | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas scale by notch count and have focused regression coverage (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). The Windows shell toolchain blocker is resolved, but this is a genuine, not merely missing-evidence, product gap: there is no `WM_POINTER`/`WM_GESTURE`/`GESTURECONFIG` handling anywhere in `graphcode-windows/src` today, so native pinch zoom is entirely unimplemented, and the live gate also does not yet synthesize `WM_MOUSEWHEEL` to exercise wheel-zoom live. Implementing real touch-gesture routing is a cross-file change to the main window message loop outside `GraphCanvas.zig` ownership and was not attempted this pass rather than risk an unverified, untested touch-input pipeline | Partial | +| Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu. The Windows shell toolchain blocker is resolved and `Tools\windows\uia-live-gate.ps1` now runs against the live executable: it locates the `zoom-out`, `actual-size`, `zoom-in`, and `fit-canvas` UIA fragments, requires non-empty bounds, resolves each `InvokePattern`, and then actually invokes zoom-in, actual-size, zoom-out, and fit-canvas in sequence against the running shell, all of which completed without error across many consecutive live runs | Validated | | New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | | Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | -| Canvas attention rail | Count/oldest context and Review action | The rail exposes a clickable Review target and now uses `createdAt` from the daemon model when present to show a true `oldest ` label alongside the oldest attention item title. Focused hit testing passes; live UIA evidence remains pending | Partial | +| Canvas attention rail | Count/oldest context and Review action | The rail exposes a clickable Review target and uses `createdAt` from the daemon model when present to show a true `oldest ` label alongside the oldest attention item title. Focused hit testing passes, and the Windows shell toolchain blocker is now resolved: the rail has no dedicated UIA element of its own (it is a full-width band GDI hit-test region), so `Tools\windows\uia-live-gate.ps1` posts a real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` at the rail's exact screen rect against the live executable and verifies the click drives `App.zig`'s `.review_attention` -> `selectNextAttention()` routing by observing the resulting `SelectionItemPattern` selection actually move from one card to the NEEDS YOU card, passing across many consecutive live runs | Validated | | Node positioning | Persisted positions and direct card movement where supported | Project cards can be dragged directly, with movement transformed correctly at non-default zoom, shared geometry/hit testing updated during the drag, and capture-loss cancellation restoring the prior position. Offsets are keyed to stable node identity, remapped across daemon reorder, and atomically persisted under the configured GraphCode support directory. Focused reorder/reload regressions and a real physical drag capture validate the complete flow | Validated | -| Connector handles | Hover handles and drag-to-connect | The right-edge connector tracks hover, paints a visible handle and plus affordance, and preserves the drag-to-connect path. Focused rendering/input coverage remains the available evidence; live hover/drag capture is still pending because the current UIA gate does not yet synthesize hover/drag pointer messages | Partial | +| Connector handles | Hover handles and drag-to-connect | The right-edge connector tracks hover, paints a visible handle and plus affordance, and preserves the drag-to-connect path. Focused rendering/input coverage passes, and the Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` synthesizes real `WM_MOUSEMOVE` hover messages at the source card's outgoing connector position and confirms the live `0x7ACDFF` hover handle pixel actually appears on screen (`Test-ScreenPixelNear`), then drives a full `WM_LBUTTONDOWN`/`WM_MOUSEMOVE`/`WM_LBUTTONUP` drag from that connector onto a second card and confirms the resulting native "Create or edit edge" dialog locks its From/To fields to the exact dragged source and dropped target loop IDs, all passing across many consecutive live runs | Validated | | Loop card identity | Loop-type stripe, title, state pill, entry/cycle role | Project and overview cards now use loop-type-colored stripes while retaining lifecycle state text, START, UNWIRED, and attention labels. Focused color regression coverage passes; live evidence remains blocked | Partial | | Loop card live detail | Goal/prompt/check line, progress, metric change, elapsed/backend/model/worktree metadata | Cards prioritize goal, trigger, or check detail, retain current activity, and add metric pass/change text, elapsed age, backend identity, token usage, model tier, and worktree/branch metadata from the same decoded daemon fields used by the workspace loop bar. Focused card metadata tests (`GraphCanvas.zig`: "loop card metadata includes backend elapsed and token usage when reported") pass, and the `windows-shell` CI job's live UIA gate exercises the populated card fixture end to end (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, passing, merged as PR #399) | Validated | | Loop card attention | Reason-aware amber presentation and primary action | NEEDS YOU cards render a card-level reason-specific primary button: `Reply` for reported awaiting-input sessions and `Inspect` for other attention reasons, both routed through the normal loop-opening path. Focused action-label tests (`GraphCanvas.zig`: "attention cards expose reason-specific primary actions") pass, and the live UIA gate's `attention-action-*` assertion for the deterministic awaiting-input card passed on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, merged as PR #399) | Validated |