Skip to content
Merged
35 changes: 34 additions & 1 deletion docs/ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,40 @@ 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. 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.

### Full smoke test example
```powershell
Expand Down
10 changes: 7 additions & 3 deletions plugins/winapp/skills/winapp-ui-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,17 @@ winapp ui invoke btn-open-e6f7 -w <dialog-hwnd>
```
Note: The filename input in standard file dialogs typically has AutomationId `1148`. Use `inspect -w <dialog-hwnd> --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. 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` / `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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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`

Expand All @@ -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
}
]
}
Expand All @@ -37,6 +47,24 @@ 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.

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
nonzero bounds.

## `ui inspect --ancestors --json`

Ancestors are now nested as a parent → child chain keyed by `Depth=i`
Expand All @@ -57,34 +85,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
{
"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
[
{
"selector": "txt-save-label-a1b2",
{
"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

Expand Down
34 changes: 34 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/FakeWindowDpiContextProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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<long, WindowDpiContext> ResultsByHwnd { get; } = [];
public Dictionary<long, Exception> ThrowsByHwnd { get; } = [];

public Exception? Throw { get; set; }

public List<long> RequestedHwnds { get; } = [];

public WindowDpiContext GetForWindow(long hwnd)
{
RequestedHwnds.Add(hwnd);
if (ThrowsByHwnd.TryGetValue(hwnd, out var hwndException))
{
throw hwndException;
}
if (Throw is not null)
{
throw Throw;
}

return ResultsByHwnd.TryGetValue(hwnd, out var result) ? result : Result;
}
}
49 changes: 49 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Inspect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -157,4 +158,52 @@ 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<UiInspectCommand>();
var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]);

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<UiInspectCommand>();
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 _));
}
}
Loading
Loading