mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
* fix(computer-use): refuse Windows pointer fallbacks without --restore-window Coordinate, right, middle, modified and multi-clicks, clicks on elements with no primary pattern, unscrollable wheel scrolls and drags raised the window and moved the real cursor without being asked. They now refuse with background_unavailable before anything is sent. Coordinate and fractional scrolls go through ScrollPattern first. With --restore-window only the target window is raised (never a cloaked one), with a UIA activation fallback and a second attempt, points are validated first and the cursor is put back behind the input. A failing UIA pattern or disabled element is an error instead of a real click, and delivery is measured after the post-action observe. * fix(computer-use): settle Windows clicks before restoring the cursor and keep scroll writes honest * fix(computer-use): restore the Windows cursor with SetCursorPos and hit-test coordinate scrolls * test(computer-use): assert Windows raise outcomes, not attempt order * fix(computer-use): report a Windows page scroll the provider ignores as not scrolled
311 lines
17 KiB
PowerShell
311 lines
17 KiB
PowerShell
param([string]$RuntimePath = (Join-Path $PSScriptRoot "runtime.ps1"))
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Assert-TestEqual($Actual, $Expected, [string]$Label) {
|
|
if ($Actual -ne $Expected) { throw "$Label expected '$Expected', received '$Actual'" }
|
|
}
|
|
|
|
function Assert-TestLike([string]$Actual, [string]$Pattern, [string]$Label) {
|
|
if ($Actual -notlike $Pattern) { throw "$Label expected '$Pattern', received '$Actual'" }
|
|
}
|
|
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Collections.Generic;
|
|
|
|
public static class OrcaDesktopWin32 {
|
|
public struct POINT { public int X; public int Y; }
|
|
|
|
public static readonly List<string> Calls = new List<string>();
|
|
// Which SetForegroundWindow call (1-based) Windows honours; 0 refuses them all.
|
|
public static int HonouredRaise;
|
|
public static bool HonourUiaActivation;
|
|
public static bool Cloaked;
|
|
public static IntPtr Foreground;
|
|
public static IntPtr Pending;
|
|
public static int ReadsSinceActivation;
|
|
public static int Raises;
|
|
public static int WindowX;
|
|
public static int TargetIntegrity;
|
|
|
|
public static void Reset(int honouredRaise) {
|
|
TargetIntegrity = 0x2000;
|
|
Calls.Clear();
|
|
HonouredRaise = honouredRaise;
|
|
HonourUiaActivation = false;
|
|
Cloaked = false;
|
|
Foreground = new IntPtr(202);
|
|
Pending = IntPtr.Zero;
|
|
ReadsSinceActivation = 0;
|
|
Raises = 0;
|
|
WindowX = 100;
|
|
}
|
|
public static void Activate(IntPtr hwnd) {
|
|
Pending = hwnd;
|
|
ReadsSinceActivation = 0;
|
|
}
|
|
public static int GetProcessIntegrityLevel(int processId) {
|
|
return processId == 4242 ? TargetIntegrity : 0x2000;
|
|
}
|
|
public static bool IsIconic(IntPtr hwnd) {
|
|
return false;
|
|
}
|
|
public static bool IsWindowCloaked(IntPtr hwnd) {
|
|
return Cloaked;
|
|
}
|
|
public static bool ShowWindow(IntPtr hwnd, int command) {
|
|
Calls.Add("restore");
|
|
return true;
|
|
}
|
|
public static bool SetForegroundWindow(IntPtr hwnd) {
|
|
Calls.Add("activate");
|
|
Raises++;
|
|
if (Raises != HonouredRaise) { return false; }
|
|
Activate(hwnd);
|
|
return true;
|
|
}
|
|
// Activation lands a moment later, and restoring the window moves its frame.
|
|
public static IntPtr GetForegroundWindow() {
|
|
if (Pending != IntPtr.Zero && ++ReadsSinceActivation >= 2) {
|
|
Foreground = Pending;
|
|
Pending = IntPtr.Zero;
|
|
WindowX = 500;
|
|
}
|
|
return Foreground;
|
|
}
|
|
public static uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId) {
|
|
processId = hwnd == new IntPtr(101) ? 4242u : 1u;
|
|
return 1;
|
|
}
|
|
public static bool GetCursorPos(out POINT point) {
|
|
point = new POINT { X = 7, Y = 8 };
|
|
return true;
|
|
}
|
|
public static bool SetCursorPos(int x, int y) {
|
|
Calls.Add("move:" + x + "," + y);
|
|
return true;
|
|
}
|
|
public static void mouse_event(uint flags, uint dx, uint dy, int data, UIntPtr extra) {
|
|
Calls.Add("mouse");
|
|
}
|
|
public static void SendModifiedClick(byte[] modifiers, uint down, uint up) {
|
|
Calls.Add("modified");
|
|
}
|
|
}
|
|
"@
|
|
|
|
$tokens = $null
|
|
$errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile($RuntimePath, [ref]$tokens, [ref]$errors)
|
|
if ($errors.Count) { throw ($errors | Out-String) }
|
|
$functions = @(
|
|
"Test-OrcaWindowFocused", "Wait-OrcaWindowFocused", "Assert-OrcaKeyboardFocus", "Restore-OrcaWindow",
|
|
"Restore-OrcaPointerWindow", "New-OrcaForegroundRefusal", "Get-OrcaCursorPosition", "Restore-OrcaCursorPosition",
|
|
"Assert-OrcaWindowTarget", "Get-OrcaRequiredNumber", "Get-OrcaPositiveInteger", "Get-OrcaPositiveNumber",
|
|
"Assert-OrcaElementFound", "Get-OrcaPointerPoint", "Get-OrcaElementScreenPoint", "Get-OrcaModifierVirtualKey",
|
|
"Get-OrcaClickModifierVirtualKeys", "Send-OrcaMouseClick", "Send-OrcaDrag", "Invoke-OrcaOperation",
|
|
"Assert-OrcaInputAllowed", "Test-OrcaTargetTookForeground", "Invoke-OrcaPrimaryAction", "Get-OrcaScrollPattern",
|
|
"Get-OrcaScrollPercent", "Get-OrcaScrollViewSize", "Get-OrcaRuntimeId", "Test-OrcaSameRuntimeId", "Wait-OrcaScrollPercentChange", "Invoke-OrcaScrollPercent",
|
|
"Invoke-OrcaScrollPattern"
|
|
)
|
|
foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) {
|
|
if ($definition.Name -in $functions) { . ([scriptblock]::Create($definition.Extent.Text)) }
|
|
}
|
|
|
|
$MouseEvents = @{ LeftDown = 2; LeftUp = 4; RightDown = 8; RightUp = 16; MiddleDown = 32; MiddleUp = 64; Wheel = 2048; HorizontalWheel = 4096 }
|
|
function Find-OrcaProcess { [pscustomobject]@{ MainWindowHandle = 101; Id = 4242; ProcessName = "Fixture" } }
|
|
function Get-OrcaAppName($Process) { $Process.ProcessName }
|
|
function Get-OrcaRootElement { $null }
|
|
function Get-OrcaWindowFrame { @{ x = [OrcaDesktopWin32]::WindowX; y = 200 } }
|
|
function Find-OrcaElement($RootElement, $Record) { if ($null -ne $Record -and -not $Record.stale) { $Record } else { $null } }
|
|
function Get-OrcaElementScreenPoint($Element) { if ($null -ne $Element) { @{ x = [OrcaDesktopWin32]::WindowX + 40; y = 240 } } else { $null } }
|
|
function Test-OrcaElementDisabled($Element) { [bool]$Element.disabled }
|
|
function Invoke-OrcaWindowPatternActivation($RootElement) {
|
|
[OrcaDesktopWin32]::Calls.Add("uia-activate")
|
|
if ([OrcaDesktopWin32]::HonourUiaActivation) { [OrcaDesktopWin32]::Activate([IntPtr]101) }
|
|
}
|
|
function Find-OrcaScrollContainerAt { $null }
|
|
function New-OrcaSnapshot { [pscustomobject]@{} }
|
|
|
|
function Invoke-TestOperation([hashtable]$Fields) {
|
|
$operation = [pscustomobject]@{ app = "Fixture"; noScreenshot = $true }
|
|
foreach ($key in $Fields.Keys) { $operation | Add-Member -NotePropertyName $key -NotePropertyValue $Fields[$key] }
|
|
try {
|
|
[pscustomobject]@{ result = Invoke-OrcaOperation $operation; message = $null }
|
|
} catch {
|
|
[pscustomobject]@{ result = $null; message = $_.Exception.Message }
|
|
}
|
|
}
|
|
|
|
function Get-TestPointerCalls {
|
|
@([OrcaDesktopWin32]::Calls | Where-Object { $_ -like "move:*" -or $_ -in @("mouse", "modified") })
|
|
}
|
|
|
|
# Elements without a primary pattern; pattern-capable ones are records with pattern = $true.
|
|
function Invoke-OrcaPrimaryActionStub($Element) { [bool]$Element.pattern }
|
|
Set-Item -Path function:Invoke-OrcaPrimaryActionReal -Value (Get-Item function:Invoke-OrcaPrimaryAction).ScriptBlock
|
|
Set-Item -Path function:Invoke-OrcaPrimaryAction -Value { param($Element, $Index) Invoke-OrcaPrimaryActionStub $Element }
|
|
Set-Item -Path function:Invoke-OrcaScrollPatternReal -Value (Get-Item function:Invoke-OrcaScrollPattern).ScriptBlock
|
|
function Invoke-OrcaScrollPattern($Element, $RootElement, $Direction, $Pages) { [bool]$Element.pattern }
|
|
|
|
$pointerCases = @(
|
|
@{ tool = "click"; x = 5; y = 6 },
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 3 } },
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 3; pattern = $true }; mouse_button = "right" },
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 3; pattern = $true }; click_count = 2 },
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 3; pattern = $true }; modifiers = "shift" },
|
|
@{ tool = "scroll"; x = 5; y = 6; direction = "down"; pages = 1 },
|
|
@{ tool = "scroll"; element = [pscustomobject]@{ index = 3 }; direction = "down"; pages = 0.5 },
|
|
@{ tool = "drag"; from_x = 5; from_y = 6; to_x = 25; to_y = 26 }
|
|
)
|
|
|
|
# Without restoreWindow, anything that needs the real mouse is refused before any raise or input.
|
|
foreach ($case in $pointerCases) {
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
$outcome = Invoke-TestOperation $case
|
|
$label = "$($case.tool) $(($case.Keys | Sort-Object) -join ',')"
|
|
Assert-TestLike $outcome.message "background_unavailable: *nothing was sent.*Ask the user before retrying with --restore-window." "$label refusal"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "$label refusal sends no raise or input"
|
|
}
|
|
|
|
# With restoreWindow: one raise, geometry read after it, and the user's pointer put back behind the input.
|
|
foreach ($case in $pointerCases) {
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
$outcome = Invoke-TestOperation ($case + @{ restoreWindow = $true })
|
|
$label = "$($case.tool) $(($case.Keys | Sort-Object) -join ',') restored"
|
|
if ($null -ne $outcome.message) { throw "$label failed: $($outcome.message)" }
|
|
Assert-TestEqual $outcome.result.action.delivery "foreground" "$label delivery"
|
|
Assert-TestEqual @([OrcaDesktopWin32]::Calls | Where-Object { $_ -eq "activate" }).Count 1 "$label raises once"
|
|
$pointer = Get-TestPointerCalls
|
|
$expectedFirst = if ($null -ne $case.element) { "move:540,240" } else { "move:505,206" }
|
|
Assert-TestEqual $pointer[0] $expectedFirst "$label geometry after delayed activation"
|
|
Assert-TestEqual $pointer[$pointer.Count - 1] "move:7,8" "$label puts the pointer back last"
|
|
}
|
|
|
|
# A refused raise falls back to the UIA route and one more raise, and sends nothing when all fail.
|
|
[OrcaDesktopWin32]::Reset(0)
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; x = 5; y = 6; restoreWindow = $true }
|
|
Assert-TestLike $outcome.message "window_not_focused: *no pointer input was sent" "refused raise"
|
|
Assert-TestEqual (Get-TestPointerCalls).Count 0 "refused raise sends no pointer input"
|
|
[OrcaDesktopWin32]::Reset(2)
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; x = 5; y = 6; restoreWindow = $true }
|
|
Assert-TestEqual $outcome.result.action.path "synthetic" "second raise honoured"
|
|
[OrcaDesktopWin32]::Reset(0)
|
|
[OrcaDesktopWin32]::HonourUiaActivation = $true
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; x = 5; y = 6; restoreWindow = $true }
|
|
Assert-TestEqual $outcome.result.action.path "synthetic" "UIA activation honoured"
|
|
|
|
# A window on another virtual desktop is never raised.
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
[OrcaDesktopWin32]::Cloaked = $true
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; x = 5; y = 6; restoreWindow = $true }
|
|
Assert-TestLike $outcome.message "window_not_focused: *another virtual desktop*" "cloaked window"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "cloaked window sends no raise or input"
|
|
|
|
# Stale and disabled targets fail before any raise.
|
|
foreach ($case in @(
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 3; stale = $true } },
|
|
@{ tool = "drag"; fromElement = [pscustomobject]@{ index = 3; stale = $true }; to_x = 25; to_y = 26 }
|
|
)) {
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
$outcome = Invoke-TestOperation ($case + @{ restoreWindow = $true })
|
|
Assert-TestLike $outcome.message "stale element frame*" "$($case.tool) stale target"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "$($case.tool) stale target sends no raise or input"
|
|
}
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; element = [pscustomobject]@{ index = 3; pattern = $true; disabled = $true }; restoreWindow = $true }
|
|
Assert-TestLike $outcome.message "element_not_enabled: element 3 is disabled*" "disabled element"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "disabled element sends no raise or input"
|
|
|
|
# UI Automation routes need no raise, even with restoreWindow, and report measured delivery.
|
|
foreach ($case in @(
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 1; pattern = $true } },
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 1; pattern = $true }; restoreWindow = $true },
|
|
@{ tool = "scroll"; element = [pscustomobject]@{ index = 1; pattern = $true }; direction = "down"; pages = 1 }
|
|
)) {
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
$outcome = Invoke-TestOperation $case
|
|
Assert-TestEqual $outcome.result.action.path "accessibility" "pattern $($case.tool) path"
|
|
Assert-TestEqual $outcome.result.action.delivery "background" "pattern $($case.tool) delivery"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "pattern $($case.tool) bypasses activation and global input"
|
|
}
|
|
foreach ($case in @(
|
|
@{ tool = "click"; element = [pscustomobject]@{ index = 1; pattern = $true } },
|
|
@{ tool = "click"; x = 5; y = 6; restoreWindow = $true },
|
|
@{ tool = "type_text"; text = "x"; restoreWindow = $true }
|
|
)) {
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
[OrcaDesktopWin32]::TargetIntegrity = 0x3000
|
|
$outcome = Invoke-TestOperation $case
|
|
Assert-TestLike $outcome.message "target_elevated:*" "$($case.tool) elevated target"
|
|
Assert-TestEqual ([OrcaDesktopWin32]::Calls.Count) 0 "$($case.tool) elevated target sends no activation or input"
|
|
}
|
|
# Why: WPF activates its own window for most patterns; the result must say so rather than claim background.
|
|
[OrcaDesktopWin32]::Reset(1)
|
|
function Invoke-OrcaPrimaryActionStub($Element) { [OrcaDesktopWin32]::Foreground = [IntPtr]101; $true }
|
|
$outcome = Invoke-TestOperation @{ tool = "click"; element = [pscustomobject]@{ index = 1 } }
|
|
Assert-TestEqual $outcome.result.action.path "accessibility" "self-activating pattern click path"
|
|
Assert-TestEqual $outcome.result.action.delivery "foreground" "self-activating pattern click delivery"
|
|
|
|
$uiaTypes = $false
|
|
try {
|
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
|
|
$uiaTypes = $true
|
|
} catch {}
|
|
if ($uiaTypes) {
|
|
function New-TestPatternElement([scriptblock]$Invoke) {
|
|
$instance = [pscustomobject]@{}
|
|
$instance | Add-Member -MemberType ScriptMethod -Name Invoke -Value $Invoke
|
|
$element = [pscustomobject]@{ instance = $instance }
|
|
$element | Add-Member -MemberType ScriptMethod -Name TryGetCurrentPattern -Value {
|
|
param($pattern, $out)
|
|
if ($pattern -ne [Windows.Automation.InvokePattern]::Pattern) { return $false }
|
|
$out.Value = $this.instance
|
|
$true
|
|
}
|
|
$element
|
|
}
|
|
$disabled = New-TestPatternElement { throw (New-Object Windows.Automation.ElementNotEnabledException) }
|
|
$message = try { Invoke-OrcaPrimaryActionReal $disabled 4 | Out-Null; $null } catch { $_.Exception.Message }
|
|
Assert-TestLike $message "element_not_enabled: element 4 is disabled*" "pattern refusing a disabled element"
|
|
$failing = New-TestPatternElement { throw (New-Object InvalidOperationException "provider gone") }
|
|
$message = try { Invoke-OrcaPrimaryActionReal $failing 4 | Out-Null; $null } catch { $_.Exception.Message }
|
|
Assert-TestLike $message "UI Automation Invoke failed on element 4: *provider gone*no mouse click was sent*" "failing pattern"
|
|
$plain = [pscustomobject]@{}
|
|
$plain | Add-Member -MemberType ScriptMethod -Name TryGetCurrentPattern -Value { param($pattern, $out) $false }
|
|
Assert-TestEqual (Invoke-OrcaPrimaryActionReal $plain 4) $false "element without a primary pattern"
|
|
|
|
# Half a page with a quarter of the content visible moves a sixth of the scroll range.
|
|
$scroll = [pscustomobject]@{ Current = [pscustomobject]@{ VerticalViewSize = 25.0; VerticalScrollPercent = 30.0 }; Calls = New-Object System.Collections.Generic.List[string] }
|
|
$scroll | Add-Member -MemberType ScriptMethod -Name SetScrollPercent -Value {
|
|
param($horizontal, $vertical)
|
|
$this.Calls.Add("$horizontal,$([Math]::Round($vertical, 4))")
|
|
$this.Current.VerticalScrollPercent = $vertical
|
|
}
|
|
Assert-TestEqual (Invoke-OrcaScrollPercent $scroll $true $true 0.5 30.0) $true "fractional scroll"
|
|
Assert-TestEqual ($scroll.Calls -join " ") "-1,46.6667" "fractional scroll percent"
|
|
$scroll.Calls.Clear()
|
|
Assert-TestEqual (Invoke-OrcaScrollPercent $scroll $true $false 9.5 46.6667) $true "fractional scroll clamps at the top"
|
|
Assert-TestEqual ($scroll.Calls -join " ") "-1,0" "fractional scroll clamped percent"
|
|
$scroll | Add-Member -Force -MemberType ScriptMethod -Name SetScrollPercent -Value { param($horizontal, $vertical) }
|
|
Assert-TestEqual (Invoke-OrcaScrollPercent $scroll $true $true 0.5 0.0) $false "fractional scroll the provider ignores"
|
|
|
|
# A page the provider ignores fails unless the view is already at that end.
|
|
function New-TestScrollElement([double]$Percent) {
|
|
$pattern = [pscustomobject]@{ Current = [pscustomobject]@{ VerticallyScrollable = $true; VerticalScrollPercent = $Percent } }
|
|
$pattern | Add-Member -MemberType ScriptMethod -Name Scroll -Value { param($horizontal, $vertical) }
|
|
$element = [pscustomobject]@{ pattern = $pattern }
|
|
$element | Add-Member -MemberType ScriptMethod -Name GetCurrentPattern -Value { param($id) $this.pattern }
|
|
$element | Add-Member -MemberType ScriptMethod -Name GetRuntimeId -Value { @(1) }
|
|
$element
|
|
}
|
|
$ignored = New-TestScrollElement 30.0
|
|
Assert-TestEqual (Invoke-OrcaScrollPatternReal $ignored $ignored "down" 1) $false "whole page the provider ignores"
|
|
$atEnd = New-TestScrollElement 100.0
|
|
Assert-TestEqual (Invoke-OrcaScrollPatternReal $atEnd $atEnd "down" 1) $true "whole page at the end of the content"
|
|
} else {
|
|
Write-Output "skipping UI Automation pattern checks: UIAutomationClient is unavailable"
|
|
}
|
|
Write-Output "windows-pointer-tests-ok"
|