From bbdd65a5d036776daf8a2e487abd3c8f20ba4203 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:26:26 -0700 Subject: [PATCH 1/8] Add typed UI geometry context Expose per-window DPI context in JSON output and add typed element metadata to get-property while preserving existing fields. Fixes #820 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ui-automation.md | 30 +++ .../skills/winapp-ui-automation/SKILL.md | 10 +- .../references/ui-json-envelope.md | 190 +++++++++++++++--- .../FakeWindowDpiContextProvider.cs | 29 +++ .../UiCommandTests.Inspect.cs | 17 ++ .../UiCommandTests.SimpleVerbs.cs | 14 ++ .../WinApp.Cli.Tests/UiCommandTests.cs | 107 +++++++++- .../WindowDpiContextProviderTests.cs | 62 ++++++ .../Commands/UiGetPropertyCommand.cs | 10 +- .../WinApp.Cli/Commands/UiInspectCommand.cs | 9 + .../WinApp.Cli/Commands/UiStatusCommand.cs | 8 + .../Helpers/HostBuilderExtensions.cs | 1 + .../Helpers/IWindowDpiContextProvider.cs | 16 ++ .../WinApp.Cli/Helpers/UiJsonContext.cs | 9 + .../Helpers/WindowDpiContextProvider.cs | 66 ++++++ src/winapp-CLI/WinApp.Cli/NativeMethods.txt | 3 + .../WinApp.UIAutomation/Models/UiElement.cs | 8 +- 17 files changed, 549 insertions(+), 40 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs diff --git a/docs/ui-automation.md b/docs/ui-automation.md index a78266cd7..313914d46 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -877,8 +877,38 @@ if ($result.matchCount -ne 1) { throw "Expected 1 Submit button, found $($result $tree = winapp ui inspect "Counter Display" -a $pid --json | ConvertFrom-Json $counter = $tree.windows[0].elements[0] if ($counter.name -ne "Count: 3") { throw "Counter value wrong: $($counter.name)" } + +# Read typed element state while preserving the legacy string property map +$property = winapp ui get-property "Counter Display" -a $pid --json | ConvertFrom-Json +if ($property.element.type -ne "Text") { throw "Unexpected type: $($property.element.type)" } +if ($property.element.isOffscreen) { throw "Counter is offscreen" } ``` +The JSON envelopes are: + +- `inspect`: `{ "depth", "interactive", "hideDisabled", "hideOffscreen", "windows": [...] }` +- `search`: `{ "matchCount", "hasMore", "matches": [...] }` +- `wait-for`: `{ "found", "waitedMs", "element"?, "timedOut" }` +- `get-property`: `{ "elementId", "element", "properties": { ... } }` + +Typed elements use `type` and numeric `x`, `y`, `width`, and `height`. +Geometry is in physical screen pixels. `0,0,0,0` is UI Automation's +empty/no-displayed-UI rectangle in this projection; `isOffscreen` is separate, +so an offscreen element can still have nonzero bounds. + +Each `inspect --json` `windows[]` entry and the `status --json` result include +`windowDpi`, `scale` (`windowDpi / 96`), `dpiAwareness`, and +`coordinateSpace: "physical-screen-pixels"`. These describe the target +window's DPI context, not unconditional monitor DPI: Windows reports 96 for an +unaware window, system DPI for a system-aware window, and current monitor DPI +for a per-monitor-aware window. If the HWND or DPI context cannot be read, +the command fails rather than silently substituting 96. When `status` resolves +a process before it has a top-level window, `hwnd` is `0` and the DPI fields are +omitted until a window exists. + +See the shipped `winapp-ui-automation` skill's +`references/ui-json-envelope.md` for complete examples of each envelope. + ### Full smoke test example ```powershell # Launch diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index d40fdc127..bd59fe655 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -355,13 +355,17 @@ winapp ui invoke btn-open-e6f7 -w ``` Note: The filename input in standard file dialogs typically has AutomationId `1148`. Use `inspect -w --interactive` to discover the actual slugs. -## JSON output envelopes (v0.3.1+) +## JSON output envelopes -The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui wait-for` was reshaped in v0.3.1. Pre-0.3.1 parsers will silently break — most fields were renamed, removed, or moved into envelopes. Highlights: +The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui wait-for` was reshaped in v0.3.1. The DPI context and typed `get-property` element are available in v0.6.3+. Highlights: - `ui inspect --json` now nests elements under `windows[].elements[]` (was a flat `elements[]`). +- Each inspected window and the `ui status --json` target reports `windowDpi`, `scale`, `dpiAwareness`, and `coordinateSpace: "physical-screen-pixels"`. This is the target window's DPI context; an invalid HWND/DPI read fails instead of defaulting to 96. - `ui get-focused --json` always emits an envelope — `{ "hasFocus": false }` or `{ "hasFocus": true, "element": {...} }` (was bare `null`). -- `ui search --json` / `ui wait-for --json` may include an `invokableAncestor` field (element-shaped) on each match. +- `ui search --json` returns `{ "matchCount", "hasMore", "matches" }`; `ui wait-for --json` returns `{ "found", "waitedMs", "element"?, "timedOut" }`. +- `ui get-property --json` preserves `elementId` and its string-valued `properties` map, and adds a typed, scrubbed `element`. +- Typed elements use `type` (not `controlType`) and numeric `x`, `y`, `width`, and `height` in physical screen pixels. `0,0,0,0` is UIA's empty/no-displayed-UI rectangle; `isOffscreen` remains independent. +- Search and wait-for elements may include an `invokableAncestor` field (element-shaped). - Per-element `id`, `parentSelector`, and `windowHandle` are **removed** — use `selector` as the public handle. Full schemas with examples: `references/ui-json-envelope.md`. diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index 6a0fa1988..95b3f86c9 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -1,8 +1,8 @@ -# `winapp ui --json` envelope (v0.3.1+) +# `winapp ui --json` envelopes -The `--json` output for the `winapp ui` command group was reshaped in v0.3.1. -Generate parsers against these shapes — pre-0.3.1 parsers will silently break -because most fields were renamed, removed, or moved into envelopes. +The `--json` output for the `winapp ui` command group uses the envelopes below. +The inspect, search, wait-for, and get-focused envelopes were reshaped in +v0.3.1; the DPI context and typed get-property element are available in v0.6.3+. ## `ui inspect --json` @@ -16,16 +16,26 @@ Top-level shape (elements are now nested under `windows[]`, not flat): "hideOffscreen": false, "windows": [ { - "hwnd": "0x...", + "hwnd": 123456, "title": "...", "className": "...", + "windowDpi": 144, + "scale": 1.5, + "dpiAwareness": "per-monitor-aware", + "coordinateSpace": "physical-screen-pixels", "elementCount": 0, "elements": [ { - "selector": "...", - "name": "...", - "controlType": "...", - "children": [ ... ] + "selector": "btn-save-c3d4", + "name": "Save", + "type": "Button", + "isEnabled": true, + "isOffscreen": false, + "x": 100, + "y": 200, + "width": 120, + "height": 32, + "isInvokable": true } ] } @@ -37,6 +47,19 @@ Pre-0.3.1 the shape was `{ "elements": [...] }`. Per-element `id`, `parentSelector`, and `windowHandle` fields have been **removed** — `selector` is the public handle. +`windowDpi` is the target window's effective DPI from `GetDpiForWindow(hwnd)`, +not unconditional monitor DPI. `scale` is `windowDpi / 96`. +`dpiAwareness` is `unaware`, `system-aware`, or `per-monitor-aware`: +`GetDpiForWindow` reports 96 for an unaware window, system DPI for a +system-aware window, and the current monitor DPI for a per-monitor-aware +window. If the HWND or DPI context cannot be read, the command fails with an +error instead of substituting 96. + +Element `x`, `y`, `width`, and `height` values are numbers in physical screen +pixels. `0,0,0,0` is UI Automation's empty/no-displayed-UI rectangle in this +projection. `isOffscreen` is independent: an offscreen element can still have +nonzero bounds. + ## `ui inspect --ancestors --json` Ancestors are now nested as a parent → child chain keyed by `Depth=i` @@ -57,34 +80,149 @@ Always emits an envelope (never a bare value): Pre-0.3.1 emitted bare `null` when nothing was focused. -## `ui search --json` / `ui wait-for --json` +## `ui search --json` + +Search returns an envelope, not a bare array: + +```json +{ + "matchCount": 1, + "hasMore": false, + "matches": [ + { + "selector": "txt-save-label-a1b2", + "name": "Save", + "type": "Text", + "isEnabled": true, + "isOffscreen": false, + "x": 100, + "y": 200, + "width": 80, + "height": 24, + "isInvokable": false, + "invokableAncestor": { + "selector": "btn-save-c3d4", + "name": "Save button", + "type": "Button", + "isEnabled": false, + "isOffscreen": false, + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "isInvokable": true + } + } + ] +} +``` -Both commands return matching elements using the same element shape as -`ui inspect` (so `selector`, `name`, `controlType`, `children`, etc.). -Each match may also include an `invokableAncestor` field — itself an +Each match may include an `invokableAncestor` field — itself an element-shaped object — pointing to the nearest parent that supports `InvokePattern` (useful when a search hits a non-invokable element like a label inside a button). +## `ui wait-for --json` + +When the condition succeeds: + ```json -[ - { - "selector": "txt-save-label-a1b2", +{ + "found": true, + "waitedMs": 125, + "element": { + "selector": "txt-status-a1b2", + "name": "Ready", + "type": "Text", + "isEnabled": true, + "isOffscreen": false, + "x": 100, + "y": 200, + "width": 80, + "height": 24, + "isInvokable": false + }, + "timedOut": false +} +``` + +On timeout, stdout still contains a parseable result and the process exits 1: + +```json +{ + "found": false, + "waitedMs": 5000, + "timedOut": true +} +``` + +With `--gone`, success after the element disappears is: + +```json +{ + "found": false, + "waitedMs": 125, + "timedOut": false +} +``` + +## `ui get-property --json` + +`elementId` and the string-valued `properties` map remain available. The +additive `element` field contains the same scrubbed typed element projection +used by search and wait-for: + +```json +{ + "elementId": "btn-save-c3d4", + "element": { + "selector": "btn-save-c3d4", "name": "Save", - "controlType": "Text", - "children": [ ... ], - "invokableAncestor": { - "selector": "btn-save-c3d4", - "name": "Save button", - "controlType": "Button" - } + "type": "Button", + "isEnabled": true, + "isOffscreen": false, + "x": 100, + "y": 200, + "width": 80, + "height": 24, + "isInvokable": true + }, + "properties": { + "Name": "Save", + "IsEnabled": "True", + "BoundingRectangle": "100,200,80,24" } -] +} ``` +The typed `element` object is the canonical way to consume geometry and boolean +state. The existing `properties` values intentionally remain strings for +backward compatibility. + +## `ui status --json` + +The resolved target also reports its window DPI context: + +```json +{ + "processId": 1234, + "processName": "MyApp", + "windowTitle": "My App", + "hwnd": 123456, + "windowDpi": 144, + "scale": 1.5, + "dpiAwareness": "per-monitor-aware", + "coordinateSpace": "physical-screen-pixels" +} +``` + +If a process resolves before it has a top-level window, `hwnd` remains `0` and +the four DPI fields are omitted. A failed DPI read for a nonzero HWND is an +error rather than a silent 96-DPI fallback. + The internal `id`, `parentSelector`, and `windowHandle` fields are -**scrubbed** from results — both at the top level and inside any nested -`invokableAncestor`. Don't depend on them; use `selector` as the handle. +**scrubbed** from typed element results — both at the top level and inside any +nested `invokableAncestor`. Don't depend on them; use `selector` as the handle. ## Error envelope diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs new file mode 100644 index 000000000..7a3a50f9d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +internal sealed class FakeWindowDpiContextProvider : IWindowDpiContextProvider +{ + public WindowDpiContext Result { get; set; } = + new(144, 1.5, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); + + public Dictionary ResultsByHwnd { get; } = []; + + public Exception? Throw { get; set; } + + public List RequestedHwnds { get; } = []; + + public WindowDpiContext GetForWindow(long hwnd) + { + RequestedHwnds.Add(hwnd); + if (Throw is not null) + { + throw Throw; + } + + return ResultsByHwnd.TryGetValue(hwnd, out var result) ? result : Result; + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs index 89ead85f0..a4ea15a48 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs @@ -157,4 +157,21 @@ public async Task Inspect_Generic_ReturnsError() var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp"]); Assert.AreEqual(1, exitCode); } + + [TestMethod] + public async Task Inspect_DpiReadFailure_ReturnsExplicitJsonError() + { + _fakeUia.InspectResult = + [ + new UiElement { Type = "Window", Depth = 0, WindowHandle = 321 }, + ]; + _fakeWindowDpiContextProvider.Throw = + new InvalidOperationException("GetDpiForWindow failed for HWND 321."); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + StringAssert.Contains(ConsoleStdErr.ToString(), "GetDpiForWindow failed for HWND 321"); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.SimpleVerbs.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.SimpleVerbs.cs index 13152decb..9a1f98470 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.SimpleVerbs.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.SimpleVerbs.cs @@ -83,6 +83,20 @@ public async Task Status_Generic_ReturnsError() Assert.AreEqual(1, exitCode); } + [TestMethod] + public async Task Status_DpiReadFailure_ReturnsExplicitJsonError() + { + _fakeTargetResolver.TargetResult.WindowHandle = 123; + _fakeWindowDpiContextProvider.Throw = + new InvalidOperationException("GetDpiForWindow failed for HWND 123."); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + StringAssert.Contains(ConsoleStdErr.ToString(), "GetDpiForWindow failed for HWND 123"); + } + // ---------- focus ---------- [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs index bce562652..29703b4e3 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs @@ -27,6 +27,7 @@ public partial class UiCommandTests : BaseCommandTests private FakeInteractiveDesktopLock _fakeDesktopLock = null!; private FakeDesktopForegroundService _fakeDesktopForeground = null!; private FakeWindowCapture _fakeWindowCapture = null!; + private FakeWindowDpiContextProvider _fakeWindowDpiContextProvider = null!; private void AssertJsonErrorCode(string expectedCode) => AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), expectedCode); @@ -55,6 +56,7 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi _fakeDesktopLock = new FakeInteractiveDesktopLock(); _fakeDesktopForeground = new FakeDesktopForegroundService(); _fakeWindowCapture = new FakeWindowCapture(); + _fakeWindowDpiContextProvider = new FakeWindowDpiContextProvider(); return services .AddSingleton(_fakeUia) .AddSingleton(_fakeRecording) @@ -68,16 +70,26 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi .AddSingleton(_fakePollDelay) .AddSingleton(_fakeDesktopLock) .AddSingleton(_fakeDesktopForeground) - .AddSingleton(_fakeWindowCapture); + .AddSingleton(_fakeWindowCapture) + .AddSingleton(_fakeWindowDpiContextProvider); } [TestMethod] public async Task Status_WithApp_ReturnsSuccess() { + _fakeTargetResolver.TargetResult.WindowHandle = 123; var command = GetRequiredService(); var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); Assert.AreEqual(0, exitCode); - StringAssert.Contains(TestAnsiConsole.Output, "\"processId\": 1234"); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + var root = document.RootElement; + Assert.AreEqual(1234, root.GetProperty("processId").GetInt32()); + Assert.AreEqual(123, root.GetProperty("hwnd").GetInt64()); + Assert.AreEqual((uint)144, root.GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual(1.5, root.GetProperty("scale").GetDouble()); + Assert.AreEqual("per-monitor-aware", root.GetProperty("dpiAwareness").GetString()); + Assert.AreEqual("physical-screen-pixels", root.GetProperty("coordinateSpace").GetString()); + CollectionAssert.AreEqual(new long[] { 123 }, _fakeWindowDpiContextProvider.RequestedHwnds); } [TestMethod] @@ -88,6 +100,25 @@ public async Task Status_WithoutApp_ReturnsError() Assert.AreEqual(1, exitCode); } + [TestMethod] + public async Task Status_Json_ProcessWithoutWindow_PreservesExistingSuccessShape() + { + _fakeTargetResolver.TargetResult.WindowHandle = 0; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + var root = document.RootElement; + Assert.AreEqual(0, root.GetProperty("hwnd").GetInt64()); + Assert.IsFalse(root.TryGetProperty("windowDpi", out _)); + Assert.IsFalse(root.TryGetProperty("scale", out _)); + Assert.IsFalse(root.TryGetProperty("dpiAwareness", out _)); + Assert.IsFalse(root.TryGetProperty("coordinateSpace", out _)); + Assert.AreEqual(0, _fakeWindowDpiContextProvider.RequestedHwnds.Count); + } + [TestMethod] public async Task Inspect_ReturnsElements() { @@ -110,6 +141,41 @@ public async Task Inspect_ReturnsElements() StringAssert.Contains(TestAnsiConsole.Output, "\"type\": \"Button\""); } + [TestMethod] + public async Task Inspect_Json_AddsDpiContextToEveryWindow() + { + _fakeWindowDpiContextProvider.ResultsByHwnd[100] = + new(96, 1, "system-aware", WindowDpiContextProvider.PhysicalScreenPixels); + _fakeWindowDpiContextProvider.ResultsByHwnd[200] = + new(192, 2, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); + _fakeUia.InspectResult = + [ + new UiElement { Type = "---", Name = "HWND 100: \"Main\" (window, MainClass)", WindowHandle = 100 }, + new UiElement { Type = "Button", Depth = 0, Selector = "btn-main" }, + new UiElement { Type = "---", Name = "HWND 200: \"Popup\" (popup, PopupClass)", WindowHandle = 200 }, + new UiElement { Type = "MenuItem", Depth = 0, Selector = "menu-popup" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + var windows = document.RootElement.GetProperty("windows"); + Assert.AreEqual(2, windows.GetArrayLength()); + var main = windows[0]; + Assert.AreEqual((uint)96, main.GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual(1, main.GetProperty("scale").GetDouble()); + Assert.AreEqual("system-aware", main.GetProperty("dpiAwareness").GetString()); + Assert.AreEqual("physical-screen-pixels", main.GetProperty("coordinateSpace").GetString()); + var popup = windows[1]; + Assert.AreEqual((uint)192, popup.GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual(2, popup.GetProperty("scale").GetDouble()); + Assert.AreEqual("per-monitor-aware", popup.GetProperty("dpiAwareness").GetString()); + Assert.AreEqual("physical-screen-pixels", popup.GetProperty("coordinateSpace").GetString()); + CollectionAssert.AreEqual(new long[] { 100, 200 }, _fakeWindowDpiContextProvider.RequestedHwnds); + } + [TestMethod] public async Task Inspect_Json_OmitsRedundantFields() { @@ -248,13 +314,44 @@ public async Task Invoke_ByElementId_ReturnsSuccess() [TestMethod] public async Task GetProperty_ReturnsProperties() { - _fakeUia.FindSingleResult = new UiElement { Id = "e0", Type = "Button", Name = "OK", IsEnabled = true }; - _fakeUia.PropertiesResult = new Dictionary { ["IsEnabled"] = true, ["Name"] = "OK" }; + _fakeUia.FindSingleResult = new UiElement + { + Id = "e0", + Selector = "btn-ok-a1b2", + Type = "Button", + Name = "OK", + IsEnabled = true, + IsOffscreen = true, + X = 0, + Y = 0, + Width = 0, + Height = 0, + WindowHandle = 123, + }; + _fakeUia.PropertiesResult = new Dictionary + { + ["IsEnabled"] = true, + ["Name"] = "OK", + }; var command = GetRequiredService(); var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["e0", "-a", "TestApp", "--json"]); Assert.AreEqual(0, exitCode); - StringAssert.Contains(TestAnsiConsole.Output, "\"elementId\": \"e0\""); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + var root = document.RootElement; + Assert.AreEqual("btn-ok-a1b2", root.GetProperty("elementId").GetString()); + Assert.AreEqual("True", root.GetProperty("properties").GetProperty("IsEnabled").GetString()); + + var element = root.GetProperty("element"); + Assert.AreEqual("Button", element.GetProperty("type").GetString()); + Assert.AreEqual("OK", element.GetProperty("name").GetString()); + Assert.AreEqual(0, element.GetProperty("x").GetDouble()); + Assert.AreEqual(0, element.GetProperty("y").GetDouble()); + Assert.AreEqual(0, element.GetProperty("width").GetDouble()); + Assert.AreEqual(0, element.GetProperty("height").GetDouble()); + Assert.IsTrue(element.GetProperty("isOffscreen").GetBoolean()); + Assert.IsFalse(element.TryGetProperty("id", out _)); + Assert.IsFalse(element.TryGetProperty("windowHandle", out _)); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs new file mode 100644 index 000000000..869246fe0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class WindowDpiContextProviderTests +{ + [TestMethod] + [DataRow(0, "unaware")] + [DataRow(1, "system-aware")] + [DataRow(2, "per-monitor-aware")] + public void GetForWindow_MapsAwarenessAndCalculatesScale(int awareness, string expected) + { + var provider = new WindowDpiContextProvider(_ => 144, _ => awareness); + + var result = provider.GetForWindow(123); + + Assert.AreEqual((uint)144, result.WindowDpi); + Assert.AreEqual(1.5, result.Scale); + Assert.AreEqual(expected, result.DpiAwareness); + Assert.AreEqual("physical-screen-pixels", result.CoordinateSpace); + } + + [TestMethod] + public void GetForWindow_ZeroHwnd_ThrowsExplicitError() + { + var provider = new WindowDpiContextProvider(_ => 96, _ => 0); + + var exception = Assert.ThrowsExactly( + () => provider.GetForWindow(0)); + + StringAssert.Contains(exception.Message, "HWND is zero"); + } + + [TestMethod] + public void GetForWindow_ZeroDpi_ThrowsExplicitError() + { + var provider = new WindowDpiContextProvider(_ => 0, _ => 0); + + var exception = Assert.ThrowsExactly( + () => provider.GetForWindow(123)); + + StringAssert.Contains(exception.Message, "GetDpiForWindow failed"); + StringAssert.Contains(exception.Message, "123"); + } + + [TestMethod] + public void GetForWindow_InvalidAwareness_ThrowsExplicitError() + { + var provider = new WindowDpiContextProvider(_ => 96, _ => -1); + + var exception = Assert.ThrowsExactly( + () => provider.GetForWindow(123)); + + StringAssert.Contains(exception.Message, "invalid value (-1)"); + StringAssert.Contains(exception.Message, "123"); + } +} + diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs index aa16c3a30..58998e9b5 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs @@ -89,13 +89,19 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn if (json) { - // Convert to string values for JSON serialization (source-gen can't handle object?) var stringProps = new Dictionary(); foreach (var kvp in props) { stringProps[kvp.Key] = kvp.Value?.ToString(); } - var result = new UiPropertyResult { ElementId = (element.Selector ?? element.Id ?? ""), Properties = stringProps }; + var elementId = element.Selector ?? element.Id ?? ""; + UiElementScrubber.Scrub(element); + var result = new UiPropertyResult + { + ElementId = elementId, + Element = element, + Properties = stringProps, + }; ansiConsole.Profile.Out.Writer.WriteLine( JsonSerializer.Serialize(result, UiJsonContext.Default.UiPropertyResult)); } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs index a8066f5db..de4956092 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs @@ -46,6 +46,7 @@ public UiInspectCommand() public partial class Handler( IUiTargetResolver targetResolver, IUiAutomation uiAutomation, + IWindowDpiContextProvider windowDpiContextProvider, IAnsiConsole ansiConsole, IInteractiveDesktopLock desktopLock, ILogger logger) : UiCoordinatedAction(desktopLock, logger) @@ -143,6 +144,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn // and surface them as ancestorPath breadcrumbs on the surviving descendants. var jsonElements = interactive ? allElements : elements; var windows = BuildWindows(jsonElements, uiTarget, interactive); + foreach (var windowInfo in windows) + { + var dpiContext = windowDpiContextProvider.GetForWindow(windowInfo.Hwnd); + windowInfo.WindowDpi = dpiContext.WindowDpi; + windowInfo.Scale = dpiContext.Scale; + windowInfo.DpiAwareness = dpiContext.DpiAwareness; + windowInfo.CoordinateSpace = dpiContext.CoordinateSpace; + } var result = new UiInspectResult { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs index ad6a5c365..c43692634 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs @@ -29,6 +29,7 @@ public UiStatusCommand() public class Handler( IUiTargetResolver targetResolver, + IWindowDpiContextProvider windowDpiContextProvider, IAnsiConsole ansiConsole, IInteractiveDesktopLock desktopLock, ILogger logger) : UiCoordinatedAction(desktopLock, logger) @@ -65,12 +66,19 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn if (json) { + var dpiContext = uiTarget.WindowHandle != 0 + ? windowDpiContextProvider.GetForWindow(uiTarget.WindowHandle) + : null; var result = new UiStatusResult { ProcessId = uiTarget.ProcessId, ProcessName = uiTarget.ProcessName, WindowTitle = uiTarget.WindowTitle, Hwnd = uiTarget.WindowHandle, + WindowDpi = dpiContext?.WindowDpi, + Scale = dpiContext?.Scale, + DpiAwareness = dpiContext?.DpiAwareness, + CoordinateSpace = dpiContext?.CoordinateSpace, }; ansiConsole.Profile.Out.Writer.WriteLine( JsonSerializer.Serialize(result, UiJsonContext.Default.UiStatusResult)); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 71d666394..b5084b91e 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -82,6 +82,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton(); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs new file mode 100644 index 000000000..22ffb714e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers; + +internal interface IWindowDpiContextProvider +{ + WindowDpiContext GetForWindow(long hwnd); +} + +internal sealed record WindowDpiContext( + uint WindowDpi, + double Scale, + string DpiAwareness, + string CoordinateSpace); + diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs index dfb702d14..6eddeb284 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs @@ -76,6 +76,10 @@ internal sealed class UiStatusResult public string ProcessName { get; set; } = ""; public string? WindowTitle { get; set; } public long Hwnd { get; set; } + public uint? WindowDpi { get; set; } + public double? Scale { get; set; } + public string? DpiAwareness { get; set; } + public string? CoordinateSpace { get; set; } } internal sealed class UiInspectResult @@ -101,6 +105,10 @@ internal sealed class UiInspectWindowInfo public long Hwnd { get; set; } public string? Title { get; set; } public string? ClassName { get; set; } + public uint WindowDpi { get; set; } + public double Scale { get; set; } + public string DpiAwareness { get; set; } = ""; + public string CoordinateSpace { get; set; } = ""; /// Total real elements (counting nested children) belonging to this window. public int ElementCount { get; set; } /// Root elements for this window. Children are nested via UiElement.Children. @@ -117,6 +125,7 @@ internal sealed class UiSearchResult internal sealed class UiPropertyResult { public string ElementId { get; set; } = ""; + public UiElement Element { get; set; } = new(); public Dictionary Properties { get; set; } = []; } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs new file mode 100644 index 000000000..3e5fdbc38 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Windows.Win32.Foundation; + +namespace WinApp.Cli.Helpers; + +internal sealed class WindowDpiContextProvider : IWindowDpiContextProvider +{ + internal const string PhysicalScreenPixels = "physical-screen-pixels"; + + private readonly Func _getDpiForWindow; + private readonly Func _getAwarenessForWindow; + + public WindowDpiContextProvider() + : this(GetDpiForWindow, GetAwarenessForWindow) + { + } + + internal WindowDpiContextProvider( + Func getDpiForWindow, + Func getAwarenessForWindow) + { + _getDpiForWindow = getDpiForWindow; + _getAwarenessForWindow = getAwarenessForWindow; + } + + public WindowDpiContext GetForWindow(long hwnd) + { + if (hwnd == 0) + { + throw new InvalidOperationException("Cannot read the target window DPI context because its HWND is zero."); + } + + var windowDpi = _getDpiForWindow(hwnd); + if (windowDpi == 0) + { + throw new InvalidOperationException($"GetDpiForWindow failed for HWND {hwnd}; the handle may no longer be valid."); + } + + var dpiAwareness = _getAwarenessForWindow(hwnd) switch + { + 0 => "unaware", + 1 => "system-aware", + 2 => "per-monitor-aware", + var value => throw new InvalidOperationException( + $"GetAwarenessFromDpiAwarenessContext returned an invalid value ({value}) for HWND {hwnd}."), + }; + + return new WindowDpiContext( + windowDpi, + windowDpi / 96d, + dpiAwareness, + PhysicalScreenPixels); + } + + private static uint GetDpiForWindow(long hwnd) + => Windows.Win32.PInvoke.GetDpiForWindow(new HWND((nint)hwnd)); + + private static int GetAwarenessForWindow(long hwnd) + { + var context = Windows.Win32.PInvoke.GetWindowDpiAwarenessContext(new HWND((nint)hwnd)); + return (int)Windows.Win32.PInvoke.GetAwarenessFromDpiAwarenessContext(context); + } +} + diff --git a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt index 607324303..a9dd736f2 100644 --- a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt +++ b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt @@ -45,6 +45,9 @@ DBG_EXCEPTION_NOT_HANDLED GetShortPathName GetFullPathName GetForegroundWindow +GetDpiForWindow +GetWindowDpiAwarenessContext +GetAwarenessFromDpiAwarenessContext SetForegroundWindow IsIconic ShowWindow diff --git a/src/winapp-CLI/WinApp.UIAutomation/Models/UiElement.cs b/src/winapp-CLI/WinApp.UIAutomation/Models/UiElement.cs index f17e258ed..8754e1f0b 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Models/UiElement.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Models/UiElement.cs @@ -28,16 +28,16 @@ public sealed class UiElement /// when the element is scrolled out of view or otherwise not on screen. public bool IsOffscreen { get; set; } - /// Left edge of the element in screen coordinates. + /// Left edge of the element in physical screen pixels. public double X { get; set; } - /// Top edge of the element in screen coordinates. + /// Top edge of the element in physical screen pixels. public double Y { get; set; } - /// Width of the element in pixels. + /// Width of the element in physical screen pixels. public double Width { get; set; } - /// Height of the element in pixels. + /// Height of the element in physical screen pixels. A 0,0,0,0 rectangle is UIA's empty/no-displayed-UI rectangle. public double Height { get; set; } /// Child elements, when the result is a nested tree. on flat result lists. From ed9e10713bc811df92d90120f9d396555d10bc95 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:27:21 -0700 Subject: [PATCH 2/8] Remove trailing blank lines Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/WindowDpiContextProviderTests.cs | 2 -- src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs | 1 - src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs | 2 -- 3 files changed, 5 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs index 869246fe0..a5ae62333 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WindowDpiContextProviderTests.cs @@ -23,7 +23,6 @@ public void GetForWindow_MapsAwarenessAndCalculatesScale(int awareness, string e Assert.AreEqual(expected, result.DpiAwareness); Assert.AreEqual("physical-screen-pixels", result.CoordinateSpace); } - [TestMethod] public void GetForWindow_ZeroHwnd_ThrowsExplicitError() { @@ -59,4 +58,3 @@ public void GetForWindow_InvalidAwareness_ThrowsExplicitError() StringAssert.Contains(exception.Message, "123"); } } - diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs index 22ffb714e..fe5c277bb 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/IWindowDpiContextProvider.cs @@ -13,4 +13,3 @@ internal sealed record WindowDpiContext( double Scale, string DpiAwareness, string CoordinateSpace); - diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs index 3e5fdbc38..933b813e4 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/WindowDpiContextProvider.cs @@ -16,7 +16,6 @@ public WindowDpiContextProvider() : this(GetDpiForWindow, GetAwarenessForWindow) { } - internal WindowDpiContextProvider( Func getDpiForWindow, Func getAwarenessForWindow) @@ -63,4 +62,3 @@ private static int GetAwarenessForWindow(long hwnd) return (int)Windows.Win32.PInvoke.GetAwarenessFromDpiAwarenessContext(context); } } - From 25815689486840eb788d159ddbd27bdccb4dc0c2 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:35:25 -0700 Subject: [PATCH 3/8] Preserve per-window inspect context Separate nested top-level HWND trees without duplication, preserve selector safety, and recover PID-only ancestor window context. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/UiCommandTests.cs | 35 ++- .../RealUiAutomationTests.Coverage.cs | 151 ++++++++++++- .../RealUiAutomationTests.cs | 15 ++ .../Services/UiAutomationService.cs | 210 +++++++++++++----- 4 files changed, 347 insertions(+), 64 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs index 29703b4e3..2953b7420 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs @@ -145,9 +145,9 @@ public async Task Inspect_ReturnsElements() public async Task Inspect_Json_AddsDpiContextToEveryWindow() { _fakeWindowDpiContextProvider.ResultsByHwnd[100] = - new(96, 1, "system-aware", WindowDpiContextProvider.PhysicalScreenPixels); - _fakeWindowDpiContextProvider.ResultsByHwnd[200] = new(192, 2, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); + _fakeWindowDpiContextProvider.ResultsByHwnd[200] = + new(120, 1.25, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); _fakeUia.InspectResult = [ new UiElement { Type = "---", Name = "HWND 100: \"Main\" (window, MainClass)", WindowHandle = 100 }, @@ -164,13 +164,13 @@ public async Task Inspect_Json_AddsDpiContextToEveryWindow() var windows = document.RootElement.GetProperty("windows"); Assert.AreEqual(2, windows.GetArrayLength()); var main = windows[0]; - Assert.AreEqual((uint)96, main.GetProperty("windowDpi").GetUInt32()); - Assert.AreEqual(1, main.GetProperty("scale").GetDouble()); - Assert.AreEqual("system-aware", main.GetProperty("dpiAwareness").GetString()); + Assert.AreEqual((uint)192, main.GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual(2, main.GetProperty("scale").GetDouble()); + Assert.AreEqual("per-monitor-aware", main.GetProperty("dpiAwareness").GetString()); Assert.AreEqual("physical-screen-pixels", main.GetProperty("coordinateSpace").GetString()); var popup = windows[1]; - Assert.AreEqual((uint)192, popup.GetProperty("windowDpi").GetUInt32()); - Assert.AreEqual(2, popup.GetProperty("scale").GetDouble()); + Assert.AreEqual((uint)120, popup.GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual(1.25, popup.GetProperty("scale").GetDouble()); Assert.AreEqual("per-monitor-aware", popup.GetProperty("dpiAwareness").GetString()); Assert.AreEqual("physical-screen-pixels", popup.GetProperty("coordinateSpace").GetString()); CollectionAssert.AreEqual(new long[] { 100, 200 }, _fakeWindowDpiContextProvider.RequestedHwnds); @@ -692,6 +692,27 @@ public async Task Inspect_Ancestors_Json_NestsChain() StringAssert.Contains(output, "\"elementCount\": 4"); } + [TestMethod] + public async Task Inspect_Ancestors_Json_UsesResolvedElementWindowForDpi() + { + _fakeTargetResolver.TargetResult.WindowHandle = 0; + _fakeUia.InspectResult = + [ + new UiElement { Type = "Window", WindowHandle = 321 }, + new UiElement { Type = "Button", WindowHandle = 321 }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, + ["btn-target", "-a", "TestApp", "--ancestors", "--json"]); + + Assert.AreEqual(0, exitCode); + CollectionAssert.AreEqual(new long[] { 321 }, _fakeWindowDpiContextProvider.RequestedHwnds); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + Assert.AreEqual(321, document.RootElement.GetProperty("windows")[0].GetProperty("hwnd").GetInt64()); + } + // --------------------------------------------------------------------- // NativeAOT smoke tests (M10): exercise each result-type registration in // UiJsonContext at least once via --json so a missing/incorrect registration diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs index 257899e9e..19bbd0636 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs @@ -252,19 +252,153 @@ public async Task ScrollErrors_NonScrollableLabelReportsNoSupportedPattern() } [TestMethod] - public async Task InspectAsync_NonExplicitSessionSkipsElementsAlreadyInMainTree() + public async Task InspectAsync_NonExplicitSessionSeparatesEnumeratedWindowAlreadyInMainTree() { using var fx = new UiaTestFixture(); - var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new UiSelectorParser()); + var svc = NewService(); + var uiTarget = NonExplicitSession(fx); + var (ownedHwnd, ownedTitle) = fx.OpenOwnedWindow( + "SeparatedOwned_" + Guid.NewGuid().ToString("N")[..6], + ownedByMain: true); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (ownedHwnd, fx.ProcessId, ownedTitle)]; + + var elements = await svc.InspectAsync(uiTarget, null, 3, CancellationToken.None); + + Assert.AreEqual(1, elements.Count(e => e.AutomationId == "btnOwned"), + "the separately enumerated HWND must not remain duplicated in the main tree"); + Assert.AreEqual("btnOwned", elements.Single(e => e.AutomationId == "btnOwned").Selector, + "a separated window must preserve its stable AutomationId selector"); + Assert.IsTrue(elements.Any(e => e.Type == "---" && e.WindowHandle == ownedHwnd), + "the nested HWND must receive its own window group and context"); + } + + [TestMethod] + public async Task InspectAsync_DuplicateAutomationIdsAcrossWindowsRemainSlugs() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + fx.OnUiThread(() => fx.InvokeButton.Name = "btnOwned"); + var (ownedHwnd, ownedTitle) = fx.OpenOwnedWindow( + "DuplicateAid_" + Guid.NewGuid().ToString("N")[..6], + ownedByMain: true); + var uiTarget = NonExplicitSession(fx); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (ownedHwnd, fx.ProcessId, ownedTitle)]; + + var elements = await svc.InspectAsync(uiTarget, null, 3, CancellationToken.None); + + var duplicates = elements.Where(element => element.AutomationId == "btnOwned").ToArray(); + Assert.AreEqual(2, duplicates.Length); + Assert.IsTrue(duplicates.All(element => element.Selector != "btnOwned"), + "an AutomationId shared by distinct windows must not become an unscoped selector"); + } + + [TestMethod] + public async Task InspectAsync_IndependentWindowAutomationIdRemainsSlug() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var (windowHwnd, windowTitle) = fx.OpenOwnedWindow( + "IndependentAid_" + Guid.NewGuid().ToString("N")[..6]); + var uiTarget = NonExplicitSession(fx); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (windowHwnd, fx.ProcessId, windowTitle)]; + + var elements = await svc.InspectAsync(uiTarget, null, 3, CancellationToken.None); + + var independentButton = elements.Single(element => element.AutomationId == "btnOwned"); + Assert.AreNotEqual("btnOwned", independentButton.Selector, + "an independent-window AutomationId must keep a slug for precise unscoped resolution"); + StringAssert.StartsWith(independentButton.Selector, "btn-btnowned-"); + } + + [TestMethod] + public async Task InspectAsync_PidOnlySessionDoesNotDuplicateRecoveredWindowInsideOwnerTree() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var (selectedHwnd, selectedTitle) = fx.OpenOwnedWindow( + "SelectedOwned_" + Guid.NewGuid().ToString("N")[..6], + ownedByMain: true); + var uiTarget = NonExplicitSession(fx); + uiTarget.WindowHandle = 0; + uiTarget.WindowTitle = selectedTitle; + UiAutomationService.s_getRootElement = (service, _) => + UiAutomationService.s_elementFromHandle(service, selectedHwnd); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(selectedHwnd, fx.ProcessId, selectedTitle), (fx.Hwnd, fx.ProcessId, fx.Title)]; + + var elements = await svc.InspectAsync(uiTarget, null, 3, CancellationToken.None); + + Assert.AreEqual(1, elements.Count(e => e.AutomationId == "btnOwned"), + "the selected HWND must not be repeated inside its separately emitted owner tree"); + Assert.AreEqual(2, elements.Count(e => e.Type == "---"), + "both selected and owner HWNDs must retain distinct window groups"); + } + + [TestMethod] + public async Task InspectAsync_UnresolvableSeparateWindowRemainsInMainTree() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var uiTarget = NonExplicitSession(fx); + var childHwnd = fx.OnUiThread(() => (nint)fx.InvokeButton.Handle); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (childHwnd, fx.ProcessId, "child")]; + UiAutomationService.s_getRootElementForHwnd = (_, _) => null; + + var elements = await svc.InspectAsync(uiTarget, null, 1, CancellationToken.None); + + Assert.AreEqual(1, elements.Count(e => e.AutomationId == "btnInvoke"), + "a window that cannot be re-rooted must remain available through the main UIA tree"); + Assert.IsFalse(elements.Any(e => e.Type == "---"), + "an unresolvable window must not create an empty separate group"); + } + + [TestMethod] + public async Task InspectAsync_LateRootRefreshFailureUsesInitiallyResolvedWindow() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); var uiTarget = NonExplicitSession(fx); var childHwnd = fx.OnUiThread(() => (nint)fx.InvokeButton.Handle); - UiAutomationService.s_getAllAppWindows = (_, _) => [(fx.Hwnd, fx.ProcessId, fx.Title), (childHwnd, fx.ProcessId, "child")]; + var calls = 0; + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (childHwnd, fx.ProcessId, "child")]; + UiAutomationService.s_getRootElementForHwnd = (service, hwnd) => + hwnd == childHwnd && ++calls == 1 + ? UiAutomationService.s_elementFromHandle(service, hwnd) + : null; + + var elements = await svc.InspectAsync(uiTarget, null, 1, CancellationToken.None); + + Assert.AreEqual(1, elements.Count(e => e.AutomationId == "btnInvoke"), + "a transient refresh failure must not lose the already-pruned window subtree"); + Assert.IsTrue(elements.Any(e => e.Type == "---" && e.WindowHandle == childHwnd)); + } + + [TestMethod] + public async Task InspectAsync_StaleInitialPopupRootDoesNotAbortMainTree() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var uiTarget = NonExplicitSession(fx); + var staleHwnd = fx.Hwnd + 1000; + var staleRoot = ComProxy((_, _) => + throw new COMException("window closed", unchecked((int)0x80040201))); + var calls = 0; + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (staleHwnd, fx.ProcessId, "closed")]; + UiAutomationService.s_getRootElementForHwnd = (_, hwnd) => + hwnd == staleHwnd && ++calls == 1 ? staleRoot : null; var elements = await svc.InspectAsync(uiTarget, null, 1, CancellationToken.None); - Assert.IsTrue(elements.Any(e => e.AutomationId == "btnInvoke")); - Assert.IsTrue(logger.Has(Microsoft.Extensions.Logging.LogLevel.Debug, "already in main window tree")); + Assert.IsTrue(elements.Any(e => e.AutomationId == "txtValue"), + "a stale secondary window must not discard the selected window tree"); + Assert.IsFalse(elements.Any(e => e.Type == "---" && e.WindowHandle == staleHwnd), + "a stale secondary window must not leave an empty group"); } [TestMethod] @@ -279,7 +413,10 @@ public async Task PidOnlySessionWithMultipleWindowsFallsBackToLargestBounds() var elements = await svc.InspectAsync(uiTarget, null, 0, CancellationToken.None); Assert.IsTrue(elements.Length > 0); - Assert.IsTrue(elements.Any(e => e.Name == fx.Title), "PID-only largest fallback should inspect the fixture window tree"); + Assert.AreEqual(1, elements.Count(e => e.Name == fx.Title), + "PID-only largest fallback should inspect the fixture window tree once"); + Assert.IsFalse(elements.Any(e => e.Type == "---" && e.WindowHandle == 0), + "the resolved root HWND must replace the PID-only target's initial zero handle"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs index 47105fc0e..c5f3e0d60 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs @@ -206,6 +206,21 @@ public async Task InspectAncestorsAsync_ReturnsRootToTargetChain() Assert.IsTrue(chain.Any(e => e.Type == "Window"), "the window ancestor should be present"); } + [TestMethod] + public async Task InspectAncestorsAsync_PidOnlyTargetSetsResolvedWindowHandle() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var uiTarget = PidOnlySession(fx); + await ResolveAsync(svc, uiTarget, "btnInvoke"); + + var chain = await svc.InspectAncestorsAsync(uiTarget, "btnInvoke", CancellationToken.None); + + Assert.IsTrue(chain.Length >= 2); + Assert.IsTrue(chain.All(element => element.WindowHandle == fx.Hwnd), + "ancestor JSON must use the target element's real top-level HWND"); + } + [TestMethod] public async Task InspectAncestorsAsync_NotFound_Throws() { diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index b48753f08..455ae9838 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -169,6 +169,12 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int return Task.FromResult([]); } + var mainHwnd = (nint)uiTarget.WindowHandle; + if (mainHwnd == 0) + { + mainHwnd = GetTopLevelWindowHandle(root); + } + // If a selector is provided, scope the tree walk to that element IUIAutomationElement startElement = root; if (!string.IsNullOrEmpty(elementId)) @@ -204,66 +210,110 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int } } - var elements = new List(); - WalkTree(startElement, depth, 0, "", elements, ref nextElementId); - - // Set WindowHandle on all elements from main window - foreach (var el in elements) - { - el.WindowHandle = uiTarget.WindowHandle; - } - - // Also walk popup/owned windows (when inspecting full tree, not scoped to element, - // and the user did not explicitly target a single HWND — see issue #472). + // A process-target inspect must keep each top-level HWND in its own output group. UIA can + // expose an owned window as a descendant of the selected root; prune those roots from the + // main walk and add them through the existing per-window path below. + var independentWindows = new List<(nint Hwnd, int Pid, string Title, IUIAutomationElement Root, bool IsInSelectedTree)>(); if (string.IsNullOrEmpty(elementId) && !uiTarget.IsExplicitWindow) { - var mainHwnd = (nint)uiTarget.WindowHandle; - var allWindows = GetAllAppWindows(uiTarget); - - // Filter out windows whose UIA root is already in the main tree (e.g., modal dialogs) - var independentWindows = new List<(nint Hwnd, int Pid, string Title)>(); - foreach (var (hwnd, pid, title) in allWindows) + foreach (var (hwnd, pid, title) in GetAllAppWindows(uiTarget)) { if (hwnd == mainHwnd) { continue; } - // Skip internal system windows (PseudoConsoleWindow, IME, etc.) var className = UiTargetResolver.GetWindowClassName(hwnd); if (IsInternalWindow(className)) { continue; } - try + var windowRoot = GetRootElementForHwnd(hwnd); + if (windowRoot is not null) { - var hwndCondition = _automation.CreatePropertyCondition( - UIA_PROPERTY_ID.UIA_NativeWindowHandlePropertyId, ComVariant.Create((int)hwnd)); - var alreadyInMain = root!.FindFirst(TreeScope.TreeScope_Descendants, hwndCondition); - if (alreadyInMain is not null) + var isInSelectedTree = false; + try + { + var hwndCondition = _automation.CreatePropertyCondition( + UIA_PROPERTY_ID.UIA_NativeWindowHandlePropertyId, + ComVariant.Create((int)hwnd)); + isInSelectedTree = + root.FindFirst(TreeScope.TreeScope_Descendants, hwndCondition) is not null; + } + catch (COMException) { - _logger.LogDebug("Skipping HWND {Hwnd} \"{Title}\" — already in main window tree", hwnd, title); - continue; + // If reachability cannot be proven, keep the precise slug selector. } + independentWindows.Add((hwnd, pid, title, windowRoot, isInSelectedTree)); } - catch { /* COM errors are non-fatal, include the window */ } - independentWindows.Add((hwnd, pid, title)); } + } - // Add header for main window when there are other independent windows - if (independentWindows.Count > 0) + var topLevelWindowHandles = independentWindows.Count > 0 + ? independentWindows.Select(window => window.Hwnd).ToHashSet() + : null; + topLevelWindowHandles?.Add(mainHwnd); + var promotableWindowHandles = new HashSet { mainHwnd }; + foreach (var window in independentWindows) + { + if (window.IsInSelectedTree) { - var mainInfo = UiTargetResolver.GetWindowInfo(mainHwnd); - var mainTitle = uiTarget.WindowTitle ?? ""; - elements.Insert(0, new UiElement - { - Id = $"--- HWND {mainHwnd}", - Type = "---", - Name = $"HWND {mainHwnd}: \"{mainTitle}\" ({mainInfo.Label}, {mainInfo.ClassName})", - Depth = 0, - WindowHandle = mainHwnd - }); + promotableWindowHandles.Add(window.Hwnd); } + } + var elements = new List(); + WalkTree( + startElement, + depth, + 0, + "", + elements, + ref nextElementId, + topLevelWindowHandles: topLevelWindowHandles, + currentWindowHandle: mainHwnd); + + // Set WindowHandle on all elements from main window + foreach (var el in elements) + { + el.WindowHandle = mainHwnd; + } + + // Also walk popup/owned windows (when inspecting full tree, not scoped to element, + // and the user did not explicitly target a single HWND — see issue #472). + if (independentWindows.Count > 0) + { + // Add header for main window when there are other independent windows + var mainInfo = UiTargetResolver.GetWindowInfo(mainHwnd); + var mainTitle = uiTarget.WindowTitle ?? ""; + elements.Insert(0, new UiElement + { + Id = $"--- HWND {mainHwnd}", + Type = "---", + Name = $"HWND {mainHwnd}: \"{mainTitle}\" ({mainInfo.Label}, {mainInfo.ClassName})", + Depth = 0, + WindowHandle = mainHwnd + }); - foreach (var (hwnd, pid, title) in independentWindows) + foreach (var (hwnd, pid, title, initialRoot, _) in independentWindows) { - var windowRoot = GetRootElementForHwnd(hwnd); - if (windowRoot is null) { continue; } + // Re-resolve immediately before walking so a transient window is not held through + // the potentially long selected-window walk. The initial root preserves the + // already-pruned subtree if the refresh transiently fails. + var windowRoot = GetRootElementForHwnd(hwnd) ?? initialRoot; + + var popupElements = new List(); + try + { + WalkTree( + windowRoot, + depth, + 0, + "", + popupElements, + ref nextElementId, + topLevelWindowHandles: topLevelWindowHandles, + currentWindowHandle: hwnd); + } + catch (COMException ex) + { + _logger.LogDebug(ex, "Skipping unavailable popup/owned window HWND {Hwnd}", hwnd); + continue; + } // Add a separator element to visually distinguish windows var info = UiTargetResolver.GetWindowInfo(hwnd); @@ -277,8 +327,6 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int WindowHandle = hwnd }); - var popupElements = new List(); - WalkTree(windowRoot, depth, 0, "", popupElements, ref nextElementId); foreach (var el in popupElements) { el.WindowHandle = hwnd; @@ -288,7 +336,7 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int } // Promote unique AutomationIds to selectors (more stable than slugs) - PromoteUniqueAutomationIds(root, elements, uiTarget.WindowHandle); + PromoteUniqueAutomationIds(root, elements, mainHwnd, promotableWindowHandles); var result = elements.ToArray(); return Task.FromResult(result); @@ -340,6 +388,7 @@ public Task InspectAncestorsAsync(UiTarget uiTarget, string element var current = target; // Add the target element itself first + var windowHandle = GetTopLevelWindowHandle(current); ancestors.Add(ToUiElement(current, "", ref nextElementId)); while (true) @@ -376,11 +425,21 @@ public Task InspectAncestorsAsync(UiTarget uiTarget, string element } ancestors.Add(ToUiElement(parent, "", ref nextElementId)); + if (windowHandle == 0) + { + windowHandle = GetTopLevelWindowHandle(parent); + } current = parent; } // Reverse so root is first, target is last ancestors.Reverse(); + windowHandle = windowHandle != 0 ? windowHandle : GetTopLevelWindowHandle(root); + windowHandle = windowHandle != 0 ? windowHandle : (nint)uiTarget.WindowHandle; + foreach (var ancestor in ancestors) + { + ancestor.WindowHandle = windowHandle; + } // Promote unique AutomationIds to selectors (more stable than slugs) PromoteUniqueAutomationIds(root, ancestors); @@ -1870,8 +1929,26 @@ private static bool IsInvokable(IUIAutomationElement element) } private void WalkTree(IUIAutomationElement element, int maxDepth, int currentDepth, string path, List results, ref int nextElementId, - string? parentSelector = null, List? ancestorTypes = null) + string? parentSelector = null, List? ancestorTypes = null, + HashSet? topLevelWindowHandles = null, nint currentWindowHandle = 0) { + if (currentDepth > 0 && topLevelWindowHandles is not null) + { + try + { + var hwnd = (nint)element.get_CurrentNativeWindowHandle(); + if (hwnd != 0 && hwnd != currentWindowHandle && topLevelWindowHandles.Contains(hwnd)) + { + return; + } + } + catch (COMException) + { + // Keep walking when a provider cannot report the native handle. The independent + // HWND is still emitted below, matching the previous best-effort COM behavior. + } + } + var uiElement = ToUiElement(element, path, ref nextElementId); uiElement.Depth = currentDepth; uiElement.ParentSelector = parentSelector; @@ -1908,7 +1985,17 @@ private void WalkTree(IUIAutomationElement element, int maxDepth, int currentDep while (child is not null) { var childPath = string.IsNullOrEmpty(path) ? $"/{childIndex}" : $"{path}/{childIndex}"; - WalkTree(child, maxDepth, currentDepth + 1, childPath, results, ref nextElementId, childParentSelector, childAncestors); + WalkTree( + child, + maxDepth, + currentDepth + 1, + childPath, + results, + ref nextElementId, + childParentSelector, + childAncestors, + topLevelWindowHandles, + currentWindowHandle); IUIAutomationElement? next; try @@ -2034,6 +2121,24 @@ private static UiElement ToUiElement(IUIAutomationElement element, string path, }; } + private static nint GetTopLevelWindowHandle(IUIAutomationElement element) + { + try + { + var native = element.get_CurrentNativeWindowHandle(); + if (native.IsNull) { return 0; } + + var root = global::Windows.Win32.PInvoke.GetAncestor( + native, + global::Windows.Win32.UI.WindowsAndMessaging.GET_ANCESTOR_FLAGS.GA_ROOT); + return root.IsNull ? (nint)native : (nint)root; + } + catch (COMException) + { + return 0; + } + } + private static bool HasPattern(IUIAutomationElement element, UIA_PATTERN_ID patternId) { try @@ -2051,7 +2156,11 @@ private static bool HasPattern(IUIAutomationElement element, UIA_PATTERN_ID patt /// across the full UIA tree, use it directly as the selector instead of a generated slug. /// AutomationIds are developer-set, stable across layout changes, and more readable. /// - private void PromoteUniqueAutomationIds(IUIAutomationElement root, IList elements, long mainWindowHandle = 0) + private void PromoteUniqueAutomationIds( + IUIAutomationElement root, + IList elements, + long mainWindowHandle = 0, + HashSet? promotableWindowHandles = null) { // Collect AutomationIds from the inspected elements that could be promoted var candidateAids = new HashSet(); @@ -2104,7 +2213,9 @@ private void PromoteUniqueAutomationIds(IUIAutomationElement root, IList getter) { try From 59d414fc3b91e840d4a4577803c911e731ed474f Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:21:11 -0700 Subject: [PATCH 4/8] Use explicit window filtering Address the Code Quality review without changing promotion behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.UIAutomation/Services/UiAutomationService.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index 455ae9838..9a59b9d1a 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -249,12 +249,9 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int : null; topLevelWindowHandles?.Add(mainHwnd); var promotableWindowHandles = new HashSet { mainHwnd }; - foreach (var window in independentWindows) + foreach (var window in independentWindows.Where(window => window.IsInSelectedTree)) { - if (window.IsInSelectedTree) - { - promotableWindowHandles.Add(window.Hwnd); - } + promotableWindowHandles.Add(window.Hwnd); } var elements = new List(); WalkTree( From 215f34e6a1a45068f6dc46a5a9cd3adf6db5638e Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:59:05 -0700 Subject: [PATCH 5/8] Use scoped element window context Resolve scoped inspect geometry against the selected element's top-level HWND. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RealUiAutomationTests.cs | 17 +++++++++++++++++ .../Services/UiAutomationService.cs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs index c5f3e0d60..1647a958d 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs @@ -191,6 +191,23 @@ public async Task InspectAsync_ScopedToElement_ReturnsSubtree() Assert.IsFalse(tree.Any(e => e.AutomationId == "btnInvoke"), "controls outside the panel must be excluded"); } + [TestMethod] + public async Task InspectAsync_ScopedOwnedElementUsesOwnedWindowHandle() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var (ownedHwnd, _) = fx.OpenOwnedWindow( + "ScopedOwned_" + Guid.NewGuid().ToString("N")[..6], + ownedByMain: true); + var uiTarget = NonExplicitSession(fx); + + var tree = await svc.InspectAsync(uiTarget, "btnOwned", 0, CancellationToken.None); + + var ownedButton = tree.Single(element => element.AutomationId == "btnOwned"); + Assert.AreEqual(ownedHwnd, ownedButton.WindowHandle, + "a scoped element must carry its own top-level HWND for DPI context"); + } + [TestMethod] public async Task InspectAncestorsAsync_ReturnsRootToTargetChain() { diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index 9a59b9d1a..b384ae371 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -207,6 +207,11 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int if (target is not null) { startElement = target; + var scopedHwnd = GetTopLevelWindowHandle(startElement); + if (scopedHwnd != 0) + { + mainHwnd = scopedHwnd; + } } } From 1cd0bc6117e6a60a2b9b55f01866116fce9c63b2 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:07:13 -0700 Subject: [PATCH 6/8] Resolve windowless scoped element HWNDs Walk UIA ancestors to derive the owned top-level window for scoped inspect and capture targeting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../UiaTestFixture.cs | 8 +++++ .../RealUiAutomationTests.cs | 8 ++--- .../UiAutomationService.CaptureSupport.cs | 18 +---------- .../Services/UiAutomationService.cs | 31 +++++++++++++++---- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs index f97853baf..97a3d497c 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs @@ -621,6 +621,14 @@ private void BuildExtraControls(Form form) Width = 160, Height = 30, }); + var ownedMenu = new MenuStrip { Name = "ownedMenu" }; + ownedMenu.Items.Add(new ToolStripMenuItem + { + Name = "mnuOwnedWindowless", + Text = "Windowless Owned Item", + AccessibleName = "Windowless Owned Item", + }); + _ownedWindow.Controls.Add(ownedMenu); _ownedWindow.Show(); } diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs index 1647a958d..4cab63411 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.cs @@ -201,11 +201,11 @@ public async Task InspectAsync_ScopedOwnedElementUsesOwnedWindowHandle() ownedByMain: true); var uiTarget = NonExplicitSession(fx); - var tree = await svc.InspectAsync(uiTarget, "btnOwned", 0, CancellationToken.None); + var tree = await svc.InspectAsync(uiTarget, "Windowless Owned Item", 0, CancellationToken.None); - var ownedButton = tree.Single(element => element.AutomationId == "btnOwned"); - Assert.AreEqual(ownedHwnd, ownedButton.WindowHandle, - "a scoped element must carry its own top-level HWND for DPI context"); + var ownedItem = tree.Single(element => element.Name == "Windowless Owned Item"); + Assert.AreEqual(ownedHwnd, ownedItem.WindowHandle, + "a windowless scoped element must inherit its owned top-level HWND for DPI context"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.CaptureSupport.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.CaptureSupport.cs index 05f6a968c..92a1b3125 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.CaptureSupport.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.CaptureSupport.cs @@ -51,23 +51,7 @@ public nint ResolveElementTopLevelWindow(UiTarget target, UiElement element) return 0; } - var walker = _automation.get_ControlViewWalker(); - var current = comElement; - var maxWalk = 40; - while (current is not null && maxWalk-- > 0) - { - var native = current.get_CurrentNativeWindowHandle(); - if (!native.IsNull) - { - var root = global::Windows.Win32.PInvoke.GetAncestor( - native, - global::Windows.Win32.UI.WindowsAndMessaging.GET_ANCESTOR_FLAGS.GA_ROOT); - return root.IsNull ? (nint)native : (nint)root; - } - current = walker.GetParentElement(current); - } - - return 0; + return ResolveTopLevelWindowHandle(comElement); } catch (System.Runtime.InteropServices.COMException ex) { diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index b384ae371..3589a7230 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -207,7 +207,7 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int if (target is not null) { startElement = target; - var scopedHwnd = GetTopLevelWindowHandle(startElement); + var scopedHwnd = ResolveTopLevelWindowHandle(startElement); if (scopedHwnd != 0) { mainHwnd = scopedHwnd; @@ -390,7 +390,7 @@ public Task InspectAncestorsAsync(UiTarget uiTarget, string element var current = target; // Add the target element itself first - var windowHandle = GetTopLevelWindowHandle(current); + var windowHandle = ResolveTopLevelWindowHandle(current); ancestors.Add(ToUiElement(current, "", ref nextElementId)); while (true) @@ -427,10 +427,6 @@ public Task InspectAncestorsAsync(UiTarget uiTarget, string element } ancestors.Add(ToUiElement(parent, "", ref nextElementId)); - if (windowHandle == 0) - { - windowHandle = GetTopLevelWindowHandle(parent); - } current = parent; } @@ -2141,6 +2137,29 @@ private static nint GetTopLevelWindowHandle(IUIAutomationElement element) } } + private nint ResolveTopLevelWindowHandle(IUIAutomationElement element) + { + var walker = _automation.get_ControlViewWalker(); + IUIAutomationElement? current = element; + var remaining = 40; + while (current is not null && remaining-- > 0) + { + var hwnd = GetTopLevelWindowHandle(current); + if (hwnd != 0) { return hwnd; } + + try + { + current = walker.GetParentElement(current); + } + catch (COMException) + { + return 0; + } + } + + return 0; + } + private static bool HasPattern(IUIAutomationElement element, UIA_PATTERN_ID patternId) { try From 04a9e7e47a4743898e46eaa0060f23ca1e01b2c1 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:34:54 -0700 Subject: [PATCH 7/8] Harden multi-window inspect context Preserve owned-window action HWNDs and surface secondary DPI failures without discarding the inspect tree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ui-automation.md | 5 ++- .../skills/winapp-ui-automation/SKILL.md | 2 +- .../references/ui-json-envelope.md | 5 +++ .../FakeWindowDpiContextProvider.cs | 5 +++ .../UiCommandTests.Inspect.cs | 32 +++++++++++++++++++ .../WinApp.Cli/Commands/UiInspectCommand.cs | 23 +++++++++---- .../WinApp.Cli/Helpers/UiJsonContext.cs | 9 +++--- .../RealUiAutomationTests.Coverage.cs | 26 +++++++++++++++ .../Services/UiAutomationService.cs | 23 +++++++++---- 9 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 313914d46..33f51f078 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -904,7 +904,10 @@ unaware window, system DPI for a system-aware window, and current monitor DPI for a per-monitor-aware window. If the HWND or DPI context cannot be read, the command fails rather than silently substituting 96. When `status` resolves a process before it has a top-level window, `hwnd` is `0` and the DPI fields are -omitted until a window exists. +omitted until a window exists. For process-wide `inspect`, the selected target +window remains fail-fast; if a later popup disappears after its tree was read, +its `windows[]` entry carries `dpiError` and omits the DPI fields while the +remaining window trees are still returned. See the shipped `winapp-ui-automation` skill's `references/ui-json-envelope.md` for complete examples of each envelope. diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index bd59fe655..3aeead3c3 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -360,7 +360,7 @@ Note: The filename input in standard file dialogs typically has AutomationId `11 The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui wait-for` was reshaped in v0.3.1. The DPI context and typed `get-property` element are available in v0.6.3+. Highlights: - `ui inspect --json` now nests elements under `windows[].elements[]` (was a flat `elements[]`). -- Each inspected window and the `ui status --json` target reports `windowDpi`, `scale`, `dpiAwareness`, and `coordinateSpace: "physical-screen-pixels"`. This is the target window's DPI context; an invalid HWND/DPI read fails instead of defaulting to 96. +- Each inspected window and the `ui status --json` target reports `windowDpi`, `scale`, `dpiAwareness`, and `coordinateSpace: "physical-screen-pixels"`. This is the target window's DPI context. The selected target fails fast on an unreadable DPI instead of defaulting to 96; a secondary window that disappears mid-walk carries `dpiError` and omits the four context fields. - `ui get-focused --json` always emits an envelope — `{ "hasFocus": false }` or `{ "hasFocus": true, "element": {...} }` (was bare `null`). - `ui search --json` returns `{ "matchCount", "hasMore", "matches" }`; `ui wait-for --json` returns `{ "found", "waitedMs", "element"?, "timedOut" }`. - `ui get-property --json` preserves `elementId` and its string-valued `properties` map, and adds a typed, scrubbed `element`. diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index 95b3f86c9..0d91fdc07 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -55,6 +55,11 @@ system-aware window, and the current monitor DPI for a per-monitor-aware window. If the HWND or DPI context cannot be read, the command fails with an error instead of substituting 96. +The selected target window remains fail-fast. If a later popup or secondary +window disappears after its UIA tree was collected, that window entry remains +in `windows[]` with a `dpiError` message and without the four DPI context fields; +the other window trees remain available. + Element `x`, `y`, `width`, and `height` values are numbers in physical screen pixels. `0,0,0,0` is UI Automation's empty/no-displayed-UI rectangle in this projection. `isOffscreen` is independent: an offscreen element can still have diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs index 7a3a50f9d..bdcf6e6aa 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs @@ -11,6 +11,7 @@ internal sealed class FakeWindowDpiContextProvider : IWindowDpiContextProvider new(144, 1.5, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); public Dictionary ResultsByHwnd { get; } = []; + public Dictionary ThrowsByHwnd { get; } = []; public Exception? Throw { get; set; } @@ -19,6 +20,10 @@ internal sealed class FakeWindowDpiContextProvider : IWindowDpiContextProvider public WindowDpiContext GetForWindow(long hwnd) { RequestedHwnds.Add(hwnd); + if (ThrowsByHwnd.TryGetValue(hwnd, out var hwndException)) + { + throw hwndException; + } if (Throw is not null) { throw Throw; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs index a4ea15a48..3247266b7 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; namespace WinApp.Cli.Tests; @@ -174,4 +175,35 @@ public async Task Inspect_DpiReadFailure_ReturnsExplicitJsonError() Assert.AreEqual(1, exitCode); StringAssert.Contains(ConsoleStdErr.ToString(), "GetDpiForWindow failed for HWND 321"); } + + [TestMethod] + public async Task Inspect_SecondaryDpiReadFailure_PreservesTreeAndSurfacesWindowError() + { + _fakeWindowDpiContextProvider.ResultsByHwnd[100] = + new(192, 2, "per-monitor-aware", WindowDpiContextProvider.PhysicalScreenPixels); + _fakeWindowDpiContextProvider.ThrowsByHwnd[200] = + new InvalidOperationException("GetDpiForWindow failed for HWND 200."); + _fakeUia.InspectResult = + [ + new UiElement { Type = "---", Name = "HWND 100: \"Main\" (window, MainClass)", WindowHandle = 100 }, + new UiElement { Type = "Button", Depth = 0, Selector = "btn-main" }, + new UiElement { Type = "---", Name = "HWND 200: \"Closing\" (popup, PopupClass)", WindowHandle = 200 }, + new UiElement { Type = "Text", Depth = 0, Selector = "txt-closing" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(TestAnsiConsole.Output); + var windows = document.RootElement.GetProperty("windows"); + Assert.AreEqual(2, windows.GetArrayLength()); + Assert.AreEqual((uint)192, windows[0].GetProperty("windowDpi").GetUInt32()); + Assert.AreEqual("txt-closing", windows[1].GetProperty("elements")[0].GetProperty("selector").GetString()); + Assert.AreEqual( + "GetDpiForWindow failed for HWND 200.", + windows[1].GetProperty("dpiError").GetString()); + Assert.IsFalse(windows[1].TryGetProperty("windowDpi", out _)); + Assert.IsFalse(windows[1].TryGetProperty("scale", out _)); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs index de4956092..4f4f9562a 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs @@ -144,13 +144,24 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn // and surface them as ancestorPath breadcrumbs on the surviving descendants. var jsonElements = interactive ? allElements : elements; var windows = BuildWindows(jsonElements, uiTarget, interactive); - foreach (var windowInfo in windows) + for (var i = 0; i < windows.Length; i++) { - var dpiContext = windowDpiContextProvider.GetForWindow(windowInfo.Hwnd); - windowInfo.WindowDpi = dpiContext.WindowDpi; - windowInfo.Scale = dpiContext.Scale; - windowInfo.DpiAwareness = dpiContext.DpiAwareness; - windowInfo.CoordinateSpace = dpiContext.CoordinateSpace; + var windowInfo = windows[i]; + try + { + var dpiContext = windowDpiContextProvider.GetForWindow(windowInfo.Hwnd); + windowInfo.WindowDpi = dpiContext.WindowDpi; + windowInfo.Scale = dpiContext.Scale; + windowInfo.DpiAwareness = dpiContext.DpiAwareness; + windowInfo.CoordinateSpace = dpiContext.CoordinateSpace; + } + catch (InvalidOperationException ex) when (i > 0) + { + // The first group is the selected target and remains fail-fast. A + // secondary transient window can disappear after its UIA walk; preserve + // the other groups and surface that window's missing context explicitly. + windowInfo.DpiError = ex.Message; + } } var result = new UiInspectResult diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs index 6eddeb284..271c57e99 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs @@ -105,10 +105,11 @@ internal sealed class UiInspectWindowInfo public long Hwnd { get; set; } public string? Title { get; set; } public string? ClassName { get; set; } - public uint WindowDpi { get; set; } - public double Scale { get; set; } - public string DpiAwareness { get; set; } = ""; - public string CoordinateSpace { get; set; } = ""; + public uint? WindowDpi { get; set; } + public double? Scale { get; set; } + public string? DpiAwareness { get; set; } + public string? CoordinateSpace { get; set; } + public string? DpiError { get; set; } /// Total real elements (counting nested children) belonging to this window. public int ElementCount { get; set; } /// Root elements for this window. Children are nested via UiElement.Children. diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs index 19bbd0636..9c7a4755d 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs @@ -273,6 +273,32 @@ public async Task InspectAsync_NonExplicitSessionSeparatesEnumeratedWindowAlread "the nested HWND must receive its own window group and context"); } + [TestMethod] + public async Task PromotedOwnedWindowSelectorResolvesWithOwnedWindowHandle() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var (ownedHwnd, ownedTitle) = fx.OpenOwnedWindow( + "SelectorRoundTrip_" + Guid.NewGuid().ToString("N")[..6], + ownedByMain: true); + var uiTarget = NonExplicitSession(fx); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title), (ownedHwnd, fx.ProcessId, ownedTitle)]; + + var elements = await svc.InspectAsync(uiTarget, null, 3, CancellationToken.None); + var inspected = elements.Single(element => element.AutomationId == "btnOwned"); + Assert.AreEqual("btnOwned", inspected.Selector); + + var resolved = await svc.FindSingleElementAsync( + uiTarget, + new UiSelector { Query = inspected.Selector }, + CancellationToken.None); + + Assert.IsNotNull(resolved); + Assert.AreEqual(ownedHwnd, resolved.WindowHandle, + "a selector emitted for an owned window must resolve back to that HWND"); + } + [TestMethod] public async Task InspectAsync_DuplicateAutomationIdsAcrossWindowsRemainSlugs() { diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index 3589a7230..b5b7a9117 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -622,10 +622,10 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int // Slug resolution: walk tree, regenerate slugs, match and validate hash if (selector.IsSlug) { - var slugResult = FindElementBySlug(selector.Slug!, root); - if (slugResult is not null) + var (slugResult, slugElement) = FindElementBySlugWithCom(selector.Slug!, root); + if (slugResult is not null && slugElement is not null) { - slugResult.WindowHandle = uiTarget.WindowHandle; + SetResolvedWindowHandle(slugResult, slugElement, uiTarget.WindowHandle); return Task.FromResult(slugResult); } // Not found on main window — search other windows (unless --window scoped us to one) @@ -651,7 +651,7 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int { var nextId = 0; var exactResult = ToUiElement(exactMatch, "", ref nextId); - exactResult.WindowHandle = uiTarget.WindowHandle; + SetResolvedWindowHandle(exactResult, exactMatch, uiTarget.WindowHandle); return Task.FromResult(exactResult); } } @@ -683,7 +683,7 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int { var nextId = 0; var manualResult = ToUiElement(manualResults[0], "", ref nextId); - manualResult.WindowHandle = uiTarget.WindowHandle; + SetResolvedWindowHandle(manualResult, manualResults[0], uiTarget.WindowHandle); return Task.FromResult(manualResult); } } @@ -722,7 +722,7 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int _logger.LogDebug("Disambiguated {Count} matches by picking the only invokable element", found.get_Length()); var nextId = 0; var invokableResult = ToUiElement(invokableMatch, "", ref nextId); - invokableResult.WindowHandle = uiTarget.WindowHandle; + SetResolvedWindowHandle(invokableResult, invokableMatch, uiTarget.WindowHandle); return Task.FromResult(invokableResult); } @@ -762,7 +762,7 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int var element = found.GetElement(0); var nextElementId = 0; var result = ToUiElement(element, "", ref nextElementId); - result.WindowHandle = uiTarget.WindowHandle; + SetResolvedWindowHandle(result, element, uiTarget.WindowHandle); // Surface invokable ancestor for non-invokable elements if (!IsInvokable(element)) @@ -2160,6 +2160,15 @@ private nint ResolveTopLevelWindowHandle(IUIAutomationElement element) return 0; } + private void SetResolvedWindowHandle( + UiElement model, + IUIAutomationElement element, + long fallbackWindowHandle) + { + var hwnd = ResolveTopLevelWindowHandle(element); + model.WindowHandle = hwnd != 0 ? hwnd : fallbackWindowHandle; + } + private static bool HasPattern(IUIAutomationElement element, UIA_PATTERN_ID patternId) { try From 1cb797155e70292e9a73fd6ee8556f908fddc19d Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:26:12 -0700 Subject: [PATCH 8/8] Prefer recovered inspect root HWND Use the live UIA root handle when a stored session window is stale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RealUiAutomationTests.Coverage.cs | 20 +++++++++++++++++++ .../Services/UiAutomationService.cs | 9 ++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs index 9c7a4755d..b0ff1a12c 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/RealUiAutomationTests.Coverage.cs @@ -445,6 +445,26 @@ public async Task PidOnlySessionWithMultipleWindowsFallsBackToLargestBounds() "the resolved root HWND must replace the PID-only target's initial zero handle"); } + [TestMethod] + public async Task InspectAsync_StaleStoredHwndUsesRecoveredRootHwnd() + { + using var fx = new UiaTestFixture(); + var svc = NewService(); + var staleHwnd = fx.Hwnd + 1000; + var uiTarget = NonExplicitSession(fx); + uiTarget.WindowHandle = staleHwnd; + UiAutomationService.s_getRootElement = (service, _) => + UiAutomationService.s_elementFromHandle(service, fx.Hwnd); + UiAutomationService.s_getAllAppWindows = (_, _) => + [(fx.Hwnd, fx.ProcessId, fx.Title)]; + + var elements = await svc.InspectAsync(uiTarget, null, 1, CancellationToken.None); + + Assert.IsTrue(elements.Length > 0); + Assert.IsTrue(elements.All(element => element.WindowHandle == fx.Hwnd), + "a recovered live root must replace a stale nonzero session HWND"); + } + [TestMethod] public async Task NamelessSlug_ResolvesPrefixHashSelector() { diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs index b5b7a9117..3d1b2c482 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.cs @@ -169,11 +169,10 @@ public Task InspectAsync(UiTarget uiTarget, string? elementId, int return Task.FromResult([]); } - var mainHwnd = (nint)uiTarget.WindowHandle; - if (mainHwnd == 0) - { - mainHwnd = GetTopLevelWindowHandle(root); - } + var resolvedRootHwnd = GetTopLevelWindowHandle(root); + var mainHwnd = resolvedRootHwnd != 0 + ? resolvedRootHwnd + : (nint)uiTarget.WindowHandle; // If a selector is provided, scope the tree walk to that element IUIAutomationElement startElement = root;