Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Tools/windows/Tests/ValidationRunner.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,32 @@ try {
throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site"
}
$shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw
if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or
$uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or
$uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or
$uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or
$uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or
$uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') {
throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup"
}
if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or
$uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or
$uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or
$uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or
$uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or
$uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') {
throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal"
}
if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or
$shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') {
throw "RED: Windows shell validation does not run the context menu and gate fixture message tests"
}
$appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw
if ($appSource -notmatch 'fn showUiaContextMenu' -or
$appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or
$appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') {
throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever"
}
if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') {
throw "RED: Windows shell validation does not run the native updater tests"
}
Expand Down
14 changes: 14 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,20 @@ Invoke-Native "Update offer modal deferral executable tests" {
Push-Location $shellRoot
try { & $zig test src\UpdateOfferPresentation.zig } finally { Pop-Location }
}
Invoke-Native "Context menu and gate fixture message executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
if (-not $winghosttyRoot) {
$winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration"
}
$include = Join-Path $winghosttyRoot "include"
Push-Location $shellRoot
try {
& $zig test src\GraphContextMenu.zig -target x86_64-windows-msvc -lc -luser32 "-I$include"
if ($LASTEXITCODE -ne 0) { return }
& $zig test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Jump palette executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
Expand Down
239 changes: 227 additions & 12 deletions Tools/windows/uia-live-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,71 @@ public static class GraphCodeUiaGateState {
private static extern IntPtr SetActiveWindow(IntPtr window);
[DllImport("user32.dll")]
private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")]
private static extern int GetMenuItemCount(IntPtr menu);
[DllImport("user32.dll")]
private static extern uint GetMenuItemID(IntPtr menu, int position);
[DllImport("user32.dll")]
private static extern uint GetMenuState(IntPtr menu, uint item, uint flags);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")]
private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags);
public static IntPtr FindPopupMenuWindow(uint processId) {
IntPtr result = IntPtr.Zero;
EnumWindows(delegate(IntPtr window, IntPtr parameter) {
uint owner;
GetWindowThreadProcessId(window, out owner);
if (owner != processId) return true;
var actualClass = new StringBuilder(256);
GetClassName(window, actualClass, actualClass.Capacity);
if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true;
if (!IsWindowVisible(window)) return true;
result = window;
return false;
}, IntPtr.Zero);
return result;
}
// MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane
// with no MenuItem children, so the menu itself is read through the Win32
// menu API against the real HMENU the shell handed to TrackPopupMenu.
public static IntPtr PopupMenuHandle(IntPtr popup) {
if (popup == IntPtr.Zero) return IntPtr.Zero;
return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero);
}
public static int PopupMenuItemCount(IntPtr menu) {
if (menu == IntPtr.Zero) return -1;
return GetMenuItemCount(menu);
}
public static uint PopupMenuItemId(IntPtr menu, int position) {
if (menu == IntPtr.Zero) return 0;
return GetMenuItemID(menu, position);
}
public static string PopupMenuItemText(IntPtr menu, int position) {
if (menu == IntPtr.Zero) return "";
var text = new StringBuilder(512);
GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400);
return text.ToString();
}
public static uint PopupMenuItemState(IntPtr menu, int position) {
if (menu == IntPtr.Zero) return 0xFFFFFFFF;
return GetMenuState(menu, (uint)position, 0x0400);
}
// MainWindow.wm_uia_context_menu (WM_APP + 44).
public static bool PostContextMenu(IntPtr window, uint target) {
return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero);
}
public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) {
bool posted = popup != IntPtr.Zero &&
PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero);
if (!posted && owner != IntPtr.Zero) {
SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero);
}
return posted;
}
public static void CancelPopupMenu(IntPtr owner) {
if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero);
}
public static IntPtr FindChild(IntPtr parent, string className) {
return FindWindowEx(parent, IntPtr.Zero, className, null);
}
Expand Down Expand Up @@ -318,6 +383,83 @@ function Get-FocusDiagnostics([IntPtr] $expectedWindow) {
return "foreground=$(Format-WindowHandle $foreground) expected=$(Format-WindowHandle $expectedWindow) expectedIsForeground=$([GraphCodeUiaGateState]::IsForegroundWindow($expectedWindow)) foregroundPid=$foregroundProcessId foregroundProcess='$($foregroundProcess.ProcessName)' foregroundClass='$([GraphCodeUiaGateState]::WindowClass($foreground))' foregroundTitle='$([GraphCodeUiaGateState]::WindowTitle($foreground))' focused={$focusedDescription}"
}

function Wait-ForPopupMenu(
[System.Diagnostics.Process] $process,
[IntPtr] $ownerWindow,
[string] $label,
[int] $TimeoutMilliseconds = 5000,
[int] $PollMilliseconds = 50
) {
$deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds)
$popup = [IntPtr]::Zero
while ([DateTime]::UtcNow -lt $deadline -and $popup -eq [IntPtr]::Zero) {
$process.Refresh()
if ($process.HasExited) {
throw "shell exited with code $($process.ExitCode) while opening the $label context menu"
}
$popup = [GraphCodeUiaGateState]::FindPopupMenuWindow([uint32]$process.Id)
if ($popup -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds $PollMilliseconds }
}
if ($popup -eq [IntPtr]::Zero) {
Write-Host "UIA_POPUP_DIAGNOSTICS label=$label $(Get-FocusDiagnostics $ownerWindow)"
}
return $popup
}

function Get-PopupMenuItems([IntPtr] $popup) {
$menu = [GraphCodeUiaGateState]::PopupMenuHandle($popup)
if ($menu -eq [IntPtr]::Zero) { return @() }
$count = [GraphCodeUiaGateState]::PopupMenuItemCount($menu)
if ($count -lt 0) { return @() }
$items = @()
for ($position = 0; $position -lt $count; $position++) {
$state = [GraphCodeUiaGateState]::PopupMenuItemState($menu, $position)
$items += [PSCustomObject]@{
Position = $position
Id = [GraphCodeUiaGateState]::PopupMenuItemId($menu, $position)
Text = [GraphCodeUiaGateState]::PopupMenuItemText($menu, $position)
State = $state
# MF_GRAYED (0x1) and MF_DISABLED (0x2) both render an unavailable item.
Enabled = (($state -band 0x3) -eq 0)
Separator = (($state -band 0x800) -ne 0)
}
}
return $items
}

function Format-PopupMenuItems($items) {
if ($null -eq $items -or @($items).Count -eq 0) { return "<none>" }
return (@($items) | ForEach-Object {
"[$($_.Position)] id=$($_.Id) enabled=$($_.Enabled) separator=$($_.Separator) '$($_.Text)'"
}) -join '; '
}

function Close-PopupMenu(
[System.Diagnostics.Process] $process,
[IntPtr] $popup,
[IntPtr] $ownerWindow,
[string] $label,
[int] $TimeoutMilliseconds = 3000,
[int] $PollMilliseconds = 50
) {
$null = [GraphCodeUiaGateState]::DismissPopupMenu($popup, $ownerWindow)
$deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds)
$cancelled = $false
while ([DateTime]::UtcNow -lt $deadline) {
if ([GraphCodeUiaGateState]::FindPopupMenuWindow([uint32]$process.Id) -eq [IntPtr]::Zero) {
return $true
}
if (-not $cancelled -and [DateTime]::UtcNow -gt $deadline.AddMilliseconds(-1500)) {
# Escape did not take: fall back to cancelling the owner's modal loop.
[GraphCodeUiaGateState]::CancelPopupMenu($ownerWindow)
$cancelled = $true
}
Start-Sleep -Milliseconds $PollMilliseconds
}
Write-Host "UIA_POPUP_DISMISS_DIAGNOSTICS label=$label cancelSent=$cancelled $(Get-FocusDiagnostics $ownerWindow)"
return $false
}

function Wait-ForDesktopElement(
[System.Windows.Automation.AutomationElement] $desktop,
[System.Windows.Automation.Condition] $condition,
Expand Down Expand Up @@ -2088,18 +2230,85 @@ try {
"sidebar root reorder did not use the sidebar-order daemon command: $reorderCommand"

$moveProjectUnavailableReason = "Project relocation is unavailable: the daemon wire contract has no authoritative moveProject command."
# The live native project right-click context menu (GraphContextMenu.zig,
# a real Win32 TrackPopupMenu) always renders "Move Project... (unavailable:
# daemon support required)" grayed via MF_GRAYED -- see the direct,
# deterministic proof of that exact item's id/text/enabled state in
# GraphContextMenu.zig's "the real Move Project menu item is disabled with
# its explicit reason inline" test, which exercises the very function
# show() uses to build the popup. This harness has no existing capability
# to open/inspect a transient native Win32 popup menu live (no action in
# this gate does; TrackPopupMenu blocks the message loop while displayed),
# so instead we assert the two behaviors this gate CAN observe live: that
# invoking the stale/legacy command path never opens Explorer, and that it
# surfaces the exact unavailable-status reason.
$moveProjectMenuText = "Move Project... (unavailable: daemon support required)"
# Live proof of what the native project right-click menu actually renders.
# GraphContextMenu.zig builds a real Win32 TrackPopupMenu; the gate asks the
# shell to open that exact menu (MainWindow.wm_uia_context_menu -> the same
# GraphContextMenu.show() the mouse path calls) and then reads the live HMENU.
# Note the observation channel: a popup menu surfaces in the UIA tree only as
# an empty Pane with no MenuItem children, so item identity, text, and the
# MF_GRAYED state are read through MN_GETHMENU and the Win32 menu API against
# the menu the shell itself handed to TrackPopupMenu. The shell thread stays
# blocked in the menu's own modal loop while we inspect, which is why the menu
# is requested asynchronously and dismissed deterministically afterwards.
$projectPopup = [IntPtr]::Zero
for ($attempt = 1; $attempt -le 3 -and $projectPopup -eq [IntPtr]::Zero; $attempt++) {
Require (Ensure-ShellForeground $shellWindow "project context menu") `
"GraphCode shell did not reacquire foreground before the project context menu"
Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 1)) `
"project context menu request was rejected"
$projectPopup = Wait-ForPopupMenu $process $shellWindow "project"
}
Require ($projectPopup -ne [IntPtr]::Zero) `
"project context menu never opened a native popup window"
$projectMenuItems = @(Get-PopupMenuItems $projectPopup)
$projectMenuDescription = Format-PopupMenuItems $projectMenuItems
$projectMenuClosed = Close-PopupMenu $process $projectPopup $shellWindow "project"
Require $projectMenuClosed `
"project context menu did not dismiss, leaving the shell blocked in its modal loop"
$process.Refresh()
Require (-not $process.HasExited) `
"shell exited with code $($process.ExitCode) while its context menu was inspected"
Require ($projectMenuItems.Count -gt 0) `
"project context menu exposed no live items: $projectMenuDescription"
$projectMenuLabels = @($projectMenuItems | Where-Object { -not $_.Separator } |
ForEach-Object { $_.Text })
foreach ($expectedLabel in @(
"Open Project", "New Loop...`tCtrl+N", "Worktrees...", "Project Settings...",
"Show in Explorer", "Close Project", "Move to Recycle Bin...",
"Remove from GraphCode...", "Delete All Loops..."
)) {
Require ($projectMenuLabels -contains $expectedLabel) `
"project context menu omitted '$expectedLabel': $projectMenuDescription"
}
$moveProjectItem = @($projectMenuItems | Where-Object { $_.Id -eq 5149 }) | Select-Object -First 1
Require ($null -ne $moveProjectItem) `
"project context menu omitted the Move Project item (command 5149): $projectMenuDescription"
Require ($moveProjectItem.Text -eq $moveProjectMenuText) `
"project context menu Move item text drifted: '$($moveProjectItem.Text)'"
Require (-not $moveProjectItem.Enabled) `
"project context menu rendered Move Project as available: $projectMenuDescription"
$liveStatusAfterMenu = Find-FragmentById $root "status" $rawWalker
Require ($null -ne $liveStatusAfterMenu) `
"shell UIA tree stopped answering after its context menu was dismissed"

# Remote projects must not offer local-filesystem relocation at all. This is a
# negative assertion that can fail: the same show() switch appends Move and
# Move to Recycle Bin only when the project is local.
$remotePopup = [IntPtr]::Zero
for ($attempt = 1; $attempt -le 3 -and $remotePopup -eq [IntPtr]::Zero; $attempt++) {
Require (Ensure-ShellForeground $shellWindow "remote project context menu") `
"GraphCode shell did not reacquire foreground before the remote project context menu"
Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 2)) `
"remote project context menu request was rejected"
$remotePopup = Wait-ForPopupMenu $process $shellWindow "remote project"
}
Require ($remotePopup -ne [IntPtr]::Zero) `
"remote project context menu never opened a native popup window"
$remoteMenuItems = @(Get-PopupMenuItems $remotePopup)
$remoteMenuDescription = Format-PopupMenuItems $remoteMenuItems
Require (Close-PopupMenu $process $remotePopup $shellWindow "remote project") `
"remote project context menu did not dismiss, leaving the shell blocked in its modal loop"
Require (@($remoteMenuItems | Where-Object { $_.Id -eq 5145 }).Count -eq 1) `
"remote project context menu omitted Remote Connection Info: $remoteMenuDescription"
Require (@($remoteMenuItems | Where-Object { $_.Id -in @(5149, 5151, 5144) }).Count -eq 0) `
"remote project context menu offered local-only relocation or Explorer actions: $remoteMenuDescription"
$process.Refresh()
Require (-not $process.HasExited) `
"shell exited with code $($process.ExitCode) after the remote project context menu"

# The stale/legacy Move command path must still refuse to alias Explorer and
# must surface the explicit unavailable reason.
Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue
Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 17)) `
"project Move fixture mutation was rejected"
Expand Down Expand Up @@ -2456,6 +2665,12 @@ try {
focusFallbackSource = [GraphCodeUiaGateState]::FocusSourceAutomationId
providerTeardownSafe = $retainedProviderSafe
connectionFailureBannerPassed = $true
contextMenuItemCount = $projectMenuItems.Count
contextMenuMoveProjectText = $moveProjectItem.Text
contextMenuMoveProjectEnabled = $moveProjectItem.Enabled
contextMenuMoveProjectState = ("0x{0:x}" -f $moveProjectItem.State)
contextMenuDismissed = $projectMenuClosed
remoteContextMenuItemCount = $remoteMenuItems.Count
} | ConvertTo-Json -Compress
} finally {
if ($stressJob) {
Expand Down
Loading
Loading