From bcb018c013d1ce332772bccf237fc2e5438278b6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 29 Sep 2026 00:07:59 -0700 Subject: [PATCH] Verify UIA rename and Edit Details dispatch through native edit buffers Extend the Windows UIA live gate for the Node update/rename row: drive the Rename dialog Title edit (id 9904) and assert the dispatched renameNode title, and open, cancel, reopen, and submit Edit Details (Goal summary id 9100), asserting cancel dispatches nothing and submit dispatches updateNode. Write and read the dialog edits with WM_SETTEXT/WM_GETTEXT. Cross-process SetWindowText/GetWindowText only touch the stored caption, so the gate read back its own write while the app submitted the unchanged buffer. Read daemon-command.json with shared access and retry until it holds complete JSON; the shell's recorder truncates then writes, so File.ReadAllText could hit a sharing violation against its open write handle. Graph/sidebar propagation is logged but not asserted: the gate forces daemon connection failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 22 ++ Tools/windows/uia-live-gate.ps1 | 193 +++++++++++++++++- graphcode-windows/src/App.zig | 9 + 3 files changed, 219 insertions(+), 5 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 8e3db31f..30705f91 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -640,6 +640,28 @@ Start-Sleep -Seconds 60 $uiaLiveGateSource -notmatch '\$updateDialog = \$directUpdate') { throw "RED: UIA gate does not use the owned modal HWND when desktop-tree lookup omits it" } + if ($uiaLiveGateSource -notmatch 'UIA_RENAME_DISPATCH' -or + $uiaLiveGateSource -notmatch 'UIA_RENAME_INPUT' -or + $uiaLiveGateSource -notmatch 'UIA_RENAME_OUTCOME' -or + $uiaLiveGateSource -notmatch 'SetEditTextById' -or + $uiaLiveGateSource -notmatch 'Native Title edit control \(id 9904\)' -or + $uiaLiveGateSource -notmatch 'Goal summary edit control \(id 9100\)' -or + $uiaLiveGateSource -notmatch 'Update node dialog did not open' -or + $uiaLiveGateSource -notmatch 'Update node cancellation left the dialog open' -or + $uiaLiveGateSource -notmatch 'UIA_UPDATE_NODE_SUBMIT_STATE' -or + $uiaLiveGateSource -notmatch 'UIA_UPDATE_NODE_DISPATCH') { + throw "RED: UIA gate does not verify rename dispatch/result or Edit Details open, cancel, and submit" + } + if ($uiaLiveGateSource -notmatch 'SendMessageString\(edit, 0x000C' -or + $uiaLiveGateSource -notmatch 'SendMessageText\(edit, 0x000D' -or + $uiaLiveGateSource -match '(?s)SetEditTextById\(IntPtr parent, int controlId, string text\) \{[^}]*SetWindowText\(') { + throw "RED: UIA gate writes or reads cross-process edit text through the window caption instead of WM_SETTEXT/WM_GETTEXT" + } + if ($uiaLiveGateSource -notmatch 'function Read-DaemonCommandLog' -or + $uiaLiveGateSource -notmatch '\[IO\.FileShare\]::ReadWrite -bor \[IO\.FileShare\]::Delete' -or + $uiaLiveGateSource -match 'ReadAllText\(\$daemonCommandLogPath\)') { + throw "RED: UIA gate reads the daemon command log without tolerating the recorder's open write handle" + } if ($uiaLiveGateSource -notmatch '(?s)\$updateDialog = \$desktop\.FindFirst\(\s*\[System\.Windows\.Automation\.TreeScope\]::Children' -or $uiaLiveGateSource -notmatch 'UIA_UPDATE_DIALOG_CHILDREN found=' -or $uiaLiveGateSource -notmatch 'Current\.ProcessId -ne \$process\.Id') { diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 6949bc36..4bea4581 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -84,6 +84,8 @@ public static class GraphCodeUiaGateState { [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageString(IntPtr window, uint message, UIntPtr wParam, string lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] private static extern IntPtr SendMessageText( IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam ); @@ -100,6 +102,8 @@ public static class GraphCodeUiaGateState { private static extern bool SetWindowText(IntPtr window, string text); [DllImport("user32.dll")] private static extern int GetDlgCtrlID(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetDlgItem(IntPtr dialog, int controlId); [DllImport("user32.dll")] private static extern IntPtr SetFocus(IntPtr window); [DllImport("user32.dll")] @@ -479,6 +483,31 @@ public static class GraphCodeUiaGateState { ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); SendMessage(parent, 0x0111, (UIntPtr)command, edit); return true; + } + public static bool SetEditTextById(IntPtr parent, int controlId, string text) { + var edit = GetDlgItem(parent, controlId); + // Cross-process SetWindowText only updates the stored caption; WM_SETTEXT reaches the EDIT buffer. + if (edit == IntPtr.Zero || SendMessageString(edit, 0x000C, UIntPtr.Zero, text) == IntPtr.Zero) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)controlId; + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } + public static string EditTextById(IntPtr parent, int controlId) { + var edit = GetDlgItem(parent, controlId); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); + } + private static string EditBufferText(IntPtr edit) { + // Cross-process GetWindowText returns the stored caption; WM_GETTEXT reads the EDIT buffer. + int length = (int)SendMessage(edit, 0x000E, UIntPtr.Zero, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(edit, 0x000D, (UIntPtr)text.Capacity, text); + return text.ToString(); + } + public static string FirstEditText(IntPtr parent) { + var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); } } "@ -ReferencedAssemblies @( @@ -538,6 +567,37 @@ function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } +# The shell's UIA command recorder truncates then writes daemon-command.json, so +# the file can exist while its writer handle is still open. File.ReadAllText +# demands FileShare.Read and throws a sharing violation against that handle; +# read with permissive sharing and retry until the recorded JSON is complete. +function Read-DaemonCommandLog([string] $path) { + $lastFailure = "not attempted" + for ($attempt = 0; $attempt -lt 40; $attempt++) { + try { + $stream = [IO.FileStream]::new( + $path, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete + ) + try { + $reader = [IO.StreamReader]::new($stream) + $text = $reader.ReadToEnd() + } finally { + $stream.Dispose() + } + if ($text.Length -gt 0) { + $null = $text | ConvertFrom-Json -ErrorAction Stop + return $text + } + $lastFailure = "empty" + } catch { + $lastFailure = $_.Exception.Message + } + Start-Sleep -Milliseconds 50 + } + throw "daemon command log '$path' never became a complete readable record: $lastFailure" +} + function Format-WindowHandle([IntPtr] $handle) { return "0x$($handle.ToInt64().ToString('x'))" } @@ -3499,6 +3559,16 @@ try { ) } Require ($null -ne $renameDialog) "Rename Loop command did not open its native dialog" + $renameGraphBefore = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -eq "UIA loop A" + }) + $renameSidebarBefore = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' -and $_.Current.Name -eq "UIA loop A" + }) + Require (($renameGraphBefore.Count -eq 1) -and ($renameSidebarBefore.Count -eq 1)) ` + "Rename outcome probe could not identify the same loop in graph and sidebar" + $renameGraphIdentity = $renameGraphBefore[0].Current.AutomationId + $renameSidebarIdentity = $renameSidebarBefore[0].Current.AutomationId $renameElements = @($renameDialog.FindAll( [System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition @@ -3507,9 +3577,15 @@ try { Require ($renameContent -match "Choose the title shown") "Rename Loop dialog omitted its explanation" Require ($renameContent -match "(?m)^Title$") "Rename Loop dialog omitted its Title field label" Require ($renameContent -match "UIA loop A") "Rename Loop dialog did not populate the current title" - Require ([GraphCodeUiaGateState]::SetFirstEditText( - [IntPtr]$renameDialog.Current.NativeWindowHandle, "UIA renamed loop" - )) "Rename Loop dialog omitted its native editable title field" + Require ([GraphCodeUiaGateState]::SetEditTextById( + [IntPtr]$renameDialog.Current.NativeWindowHandle, 9904, "UIA renamed loop" + )) "Rename Loop dialog omitted its native Title edit control (id 9904)" + $renameInputAfterSet = [GraphCodeUiaGateState]::EditTextById( + [IntPtr]$renameDialog.Current.NativeWindowHandle, 9904 + ) + Write-Host "UIA_RENAME_INPUT requested='UIA renamed loop' observed='$renameInputAfterSet'" + Require ($renameInputAfterSet -eq "UIA renamed loop") ` + "Rename Loop edit control did not retain the requested replacement title" Require ([GraphCodeUiaGateState]::SendReturn( [IntPtr]$renameDialog.Current.NativeWindowHandle )) "Rename Loop dialog rejected Return" @@ -3522,6 +3598,113 @@ try { if ($null -eq $remainingRename) { break } } Require ($null -eq $remainingRename) "Return did not submit and close the Rename Loop dialog" + $renameCommandJson = (Read-DaemonCommandLog $daemonCommandLogPath) + $renameCommand = ConvertFrom-Json -InputObject $renameCommandJson + Require ($renameCommand.graphCommand.command.renameNode._0 -eq + "11111111-1111-4111-8111-111111111111") ` + "Rename submission did not dispatch the fixture node identity" + $renameDispatchMatches = $renameCommand.graphCommand.command.renameNode.title -eq "UIA renamed loop" + Write-Host "UIA_RENAME_DISPATCH nodeId=$($renameCommand.graphCommand.command.renameNode._0) title=$($renameCommand.graphCommand.command.renameNode.title) expectedTitle='UIA renamed loop' titleMatches=$renameDispatchMatches connectionFailure=forced" + $renamedGraphCard = Find-FragmentById $root $renameGraphIdentity $rawWalker + $renamedSidebarRow = Find-FragmentById $root $renameSidebarIdentity $rawWalker + $renameGraphTitle = [string]$renamedGraphCard.Current.Name + $renameSidebarTitle = [string]$renamedSidebarRow.Current.Name + Write-Host "UIA_RENAME_OUTCOME graph='$renameGraphTitle' sidebar='$renameSidebarTitle' expected='UIA renamed loop' reason=gate-forces-daemon-connection-failure" + + $updateWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Update node" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + )) + ) + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 21)) ` + "Edit Details open fixture command was rejected" + $updateDialog = $null + for ($index = 0; $index -lt 40 -and $null -eq $updateDialog; $index++) { + Start-Sleep -Milliseconds 50 + $updateDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $updateWindowCondition + ) + } + Require ($null -ne $updateDialog) "Update node dialog did not open" + $renameCommandAfterCancel = (Read-DaemonCommandLog $daemonCommandLogPath) + Require ([GraphCodeUiaGateState]::SendCommand( + [IntPtr]$updateDialog.Current.NativeWindowHandle, 2 + )) "Edit Details dialog rejected Cancel" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingUpdate = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $updateWindowCondition + ) + if ($null -eq $remainingUpdate) { break } + } + Require ($null -eq $remainingUpdate) "Update node cancellation left the dialog open" + Require ((Read-DaemonCommandLog $daemonCommandLogPath) -ceq $renameCommandAfterCancel) ` + "Edit Details cancellation dispatched a daemon command" + Write-Host "UIA_EDIT_DETAILS open=True cancel=closed commandUnchanged=True" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 21)) ` + "Edit Details submit fixture command was rejected" + $updateDialog = $null + for ($index = 0; $index -lt 40 -and $null -eq $updateDialog; $index++) { + Start-Sleep -Milliseconds 50 + $updateDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $updateWindowCondition + ) + } + Require ($null -ne $updateDialog) "Update node dialog did not reopen for submission" + Require ([GraphCodeUiaGateState]::SetEditTextById( + [IntPtr]$updateDialog.Current.NativeWindowHandle, 9100, "UIA details submitted" + )) "Edit Details dialog omitted its native Goal summary edit control (id 9100)" + $updateInputAfterSet = [GraphCodeUiaGateState]::EditTextById( + [IntPtr]$updateDialog.Current.NativeWindowHandle, 9100 + ) + Write-Host "UIA_UPDATE_NODE_INPUT requested='UIA details submitted' observed='$updateInputAfterSet'" + Require ([GraphCodeUiaGateState]::SendCommand( + [IntPtr]$updateDialog.Current.NativeWindowHandle, 1 + )) "Edit Details dialog rejected Submit" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingUpdate = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $updateWindowCondition + ) + if ($null -eq $remainingUpdate) { break } + } + if ($null -ne $remainingUpdate) { + $updateSubmitElements = @($remainingUpdate.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + )) + $updateSubmitContent = @($updateSubmitElements | ForEach-Object { + $_.Current.Name + }) -join "|" + $updateSubmitFirstEdit = [GraphCodeUiaGateState]::FirstEditText( + [IntPtr]$remainingUpdate.Current.NativeWindowHandle + ) + Write-Host "UIA_UPDATE_NODE_SUBMIT_STATE stillOpen=True firstEdit='$updateSubmitFirstEdit' content='$updateSubmitContent'" + } + Require ($null -eq $remainingUpdate) "Edit Details submission left the dialog open" + $updateCommandJson = (Read-DaemonCommandLog $daemonCommandLogPath) + $updateCommand = ConvertFrom-Json -InputObject $updateCommandJson + Require (($updateCommand.graphCommand.command.updateNode._0 -eq + "11111111-1111-4111-8111-111111111111") -and + ($updateCommand.graphCommand.command.updateNode.update.goalSummary -eq + "UIA details submitted")) "Edit Details did not dispatch the edited summary for the fixture node" + Write-Host "UIA_UPDATE_NODE_DISPATCH nodeId=$($updateCommand.graphCommand.command.updateNode._0) goalSummary=$($updateCommand.graphCommand.command.updateNode.update.goalSummary) connectionFailure=forced" + Require ($renameDispatchMatches) ` + "Rename submission dispatched a title different from the requested replacement" Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 14)) ` "jump palette fixture command was rejected" @@ -4025,7 +4208,7 @@ try { Start-Sleep -Milliseconds 50 } Require (Test-Path -LiteralPath $daemonCommandLogPath) "Needs-you Stop did not emit a daemon command" - $needsYouStopCommand = [IO.File]::ReadAllText($daemonCommandLogPath) + $needsYouStopCommand = (Read-DaemonCommandLog $daemonCommandLogPath) Require (($needsYouStopCommand | ConvertFrom-Json).graphCommand.projectPath -eq $fixtureProjectPath) ` "Needs-you Stop routed to the wrong project: $needsYouStopCommand" Require ($needsYouStopCommand -match '"stopNode":\{"_0":"22222222-2222-4222-8222-222222222222"\}') ` @@ -4047,7 +4230,7 @@ try { (($reorderedRootNames -join '|') -eq 'UIA loop C|UIA loop A|UIA loop B')) ` "sidebar root reorder was not observable in the loop tree: $($reorderedRootNames -join '|')" Require (Test-Path -LiteralPath $daemonCommandLogPath) "sidebar root reorder did not emit a daemon command" - $reorderCommand = [IO.File]::ReadAllText($daemonCommandLogPath) + $reorderCommand = (Read-DaemonCommandLog $daemonCommandLogPath) Require ($reorderCommand -match '"sidebarNodesReordered"') ` "sidebar root reorder did not use the sidebar-order daemon command: $reorderCommand" diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a7eaf9a7..f76074a3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -4122,6 +4122,10 @@ pub const App = struct { } if (mutation == 7) { self.closeCompositeGroup(); + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.syncAccessibility(); _ = self.model.setSelectedID("11111111-1111-4111-8111-111111111111"); self.editSelectedNode(); return; @@ -4271,6 +4275,11 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); return; } + if (mutation == 21) { + _ = self.model.setSelectedID("11111111-1111-4111-8111-111111111111"); + self.editSelectedNodeDetails(); + return; + } const dialog = if (self.worktree_dialog) |*value| value else return; const target_path = if (mutation == 2 or mutation == 3) self.ownedUiaFixturePath(if (mutation == 2) .safe else .unsafe) catch |err| { self.reportWorktreeError("UIA fixture owner unavailable", err);