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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -856,12 +856,17 @@ the accessibility label instead, use `get-property --property Name`.
### focus
```bash
winapp ui focus txt-textbox-a4b1 -a notepad
winapp ui focus -a calculator # no selector: restore and activate the window itself
```

Activates the selected control's window when needed, then focuses the control.
The selector is required; use `-a <app>` or `-w <HWND>` to choose the target.
Success means that window was foreground and the selected control confirmed
`HasKeyboardFocus` before the command returned. The command allows up to 500 ms
Use `-a <app>` or `-w <HWND>` to choose the target. If the window is minimized,
`focus` restores it first; this matters for packaged apps such as Calculator,
which hide their controls while minimized. Without a selector, `focus` restores
and activates the target window and succeeds once it is in the foreground.
With a selector, success means that window was foreground and the selected control confirmed
`HasKeyboardFocus` before the command returned (a selected `Window` element only
needs to be in the foreground). The command allows up to 500 ms
for the control to report focus; it stops if the target disappears or loses the
foreground rather than trying to take focus back. An owned dialog in front of the
main window is not enough: select a control in the dialog if that is your target.
Expand Down Expand Up @@ -1044,6 +1049,7 @@ for example `MSTest.Windows.UIAutomation`, whose `WindowTest.MainWindow` is a UI
| "does not support any invoke pattern" | Element can't be invoked | Use `inspect` on the element to find an invokable child |
| "No UIA window found" | UIA can't see the process | Use `list-windows` to find the HWND, then `-w` |
| "Window has zero size" | Window is minimized | App will be auto-restored |
| `element_not_found` or an empty `search`/`inspect` with "is minimized" | Some apps, such as Calculator, hide their UI while minimized | Run the suggested `winapp ui focus -w <HWND>` (or `winapp ui focus -a <app>`), then retry |
| Popup/dropdown not in screenshot | Default capture is per-window and doesn't include unowned overlays | Follow the [screenshot overlay workflow](#screenshot) to select a window with `-w <hwnd> --capture-screen` |
| `foreground_not_target` from `--capture-screen` | Windows refused the activation, so a screen capture would have recorded whatever window is actually in front | Click the target window or close the focus-stealing window and retry, or drop `--capture-screen` |
| `element_not_found` during record | Selector given but no matching element | Re-run `inspect` or `search` to get a fresh selector |
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2289,7 +2289,7 @@ winapp ui [command] [options]
- `pen` - Inject synthetic pen/stylus input — taps and ink strokes with configurable pressure, tilt, and eraser mode
- `send-keys` - Send synthetic keyboard input (named keys, combos, raw vk=0xNN, or literal text) to a window
- `set-value` - Set value on editable element (text, number); falls back to LegacyIAccessible `put_accValue` for TextPattern-only rich-edit controls
- `focus` - Move keyboard focus
- `focus` - Move keyboard focus to an element, or restore and activate the window when no selector is given
- `scroll-into-view` - Scroll element visible
- `wait-for` - Wait for element state
- `list-windows` - List all windows for an app
Expand Down
5 changes: 4 additions & 1 deletion plugins/winapp/skills/winapp-ui-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,10 @@ for units and state meanings, and `references/ui-json-envelope.md` for the JSON
### Set values
To prepare a control for keyboard input, use `winapp ui focus <selector> -a <app>`
(keep `--on sandbox` when working in Sandbox). It activates the control's window
and verifies both foreground and keyboard focus before success. Do not use a
and verifies both foreground and keyboard focus before success. It restores a
minimized window first. If a command reports the target window is minimized (some
apps, such as Calculator, hide their UI until restored), run
`winapp ui focus -a <app>` (no selector) to restore and activate it, then retry. Do not use a
screenshot as a focus workaround. If activation is refused, inspect for a blocking
dialog and ask the user to activate the intended window; do not retry in a loop
or switch to the local desktop. See the [focus reference](https://github.com/microsoft/winappcli/blob/main/docs/ui-automation.md#focus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public bool IsForeground(long hwnd)

public bool IsMinimized(long hwnd) => AllWindowsMinimized || MinimizedWindows.Contains(hwnd);

public void Restore(long hwnd) => RestoreRequests.Add(hwnd);
public void Restore(long hwnd)
{
RestoreRequests.Add(hwnd);
MinimizedWindows.Remove(hwnd);
}

public void ShowWithoutActivation(long hwnd) => ShowWithoutActivationRequests.Add(hwnd);
}
122 changes: 119 additions & 3 deletions src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Focus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,127 @@ public async Task Focus_ProviderVerificationFailure_DoesNotReportSuccess()
}

[TestMethod]
public void Focus_HelpRequiresSelectorWithoutChangingOtherCommands()
public void Focus_SelectorIsOptionalForWindowFocus()
{
var focus = GetRequiredService<UiFocusCommand>();
Assert.AreEqual(1, focus.Arguments.Single().Arity.MinimumNumberOfValues);
Assert.AreEqual(0, SharedUiOptions.SelectorArgument.Arity.MinimumNumberOfValues);
Assert.AreEqual(0, focus.Arguments.Single().Arity.MinimumNumberOfValues);
Assert.AreEqual(1, focus.Arguments.Single().Arity.MaximumNumberOfValues);
}

[TestMethod]
public async Task Focus_MinimizedWindow_RestoresThenFindsElement()
{
// Packaged apps such as Calculator expose no content while minimized: the element only
// appears once the window is restored.
ConfigureVerifiedFocus();
var element = _fakeUia.FindSingleResult;
_fakeUia.FindSingleResult = null;
var lookups = 0;
_fakeUia.OnFindSingle = () =>
{
if (++lookups == 3)
{
_fakeUia.FindSingleResult = element;
}
};
_fakeDesktopForeground.MinimizedWindows.Add(4242);

Assert.AreEqual(0, await RunVerifiedFocusAsync(), ConsoleStdErr.ToString());
CollectionAssert.AreEqual(new long[] { 4242 }, _fakeDesktopForeground.RestoreRequests);
Assert.AreEqual(1, _fakePollDelay.CallCount);
Assert.AreSame(element, _fakeUia.LastFocusedElement);
}

[TestMethod]
public async Task Focus_MinimizedWindow_ElementNeverAppears_FailsWithoutFocusing()
{
ConfigureVerifiedFocus();
_fakeUia.FindSingleResult = null;
_fakeDesktopForeground.MinimizedWindows.Add(4242);

Assert.AreEqual(1, await RunVerifiedFocusAsync());
CollectionAssert.AreEqual(new long[] { 4242 }, _fakeDesktopForeground.RestoreRequests);
Assert.IsNull(_fakeUia.LastFocusedElement);
AssertJsonErrorCode("element_not_found");
}

[TestMethod]
public async Task Focus_ElementMissingInVisibleWindow_DoesNotRestoreOrWait()
{
ConfigureVerifiedFocus();
_fakeUia.FindSingleResult = null;

Assert.AreEqual(1, await RunVerifiedFocusAsync());
Assert.IsEmpty(_fakeDesktopForeground.RestoreRequests);
Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections);
AssertJsonErrorCode("element_not_found");
}

[TestMethod]
public async Task Focus_WindowElement_IsConfirmedByForegroundNotKeyboardFocus()
{
// A window's root element never reports HasKeyboardFocus; focus lands on a descendant.
ConfigureVerifiedFocus();
_fakeUia.FindSingleResult = new UiElement { Id = "win", Selector = "win-app-1234", Type = "Window", WindowHandle = 4242 };
_fakeUia.OnFocus = () => { };
_fakeUia.OnGetProperties = (_, _) => Assert.Fail("A window element must not wait for HasKeyboardFocus.");

Assert.AreEqual(0, await ParseAndInvokeWithCaptureAsync(
GetRequiredService<UiFocusCommand>(), ["win-app-1234", "-a", "TestApp", "--json"]));
}

[TestMethod]
public async Task Focus_NoSelector_RestoresAndActivatesTargetWindow()
{
ConfigureVerifiedFocus();
_fakeSystemQuery.ForegroundWindowResult = 9000;
_fakeDesktopForeground.MinimizedWindows.Add(4242);
_fakeDesktopForeground.OnRequestForeground = hwnd =>
{
Assert.AreEqual(1, _fakeDesktopLock.OpenDesktopSections);
Assert.HasCount(1, _fakeDesktopForeground.RestoreRequests);
_fakeSystemQuery.ForegroundWindowResult = (nint)hwnd;
};

Assert.AreEqual(0, await ParseAndInvokeWithCaptureAsync(
GetRequiredService<UiFocusCommand>(), ["-a", "TestApp", "--json"]));
CollectionAssert.AreEqual(new long[] { 4242 }, _fakeDesktopForeground.RestoreRequests);
CollectionAssert.AreEqual(new long[] { 4242 }, _fakeDesktopForeground.ForegroundRequests);
Assert.IsNull(_fakeUia.LastFocusedElement);
StringAssert.Contains(TestAnsiConsole.Output, "\"hwnd\": 4242");
}

[TestMethod]
public async Task Focus_NoSelector_ActivationRefused_Fails()
{
ConfigureVerifiedFocus();
_fakeSystemQuery.ForegroundWindowResult = 9000;

Assert.AreEqual(1, await ParseAndInvokeWithCaptureAsync(
GetRequiredService<UiFocusCommand>(), ["-a", "TestApp", "--json"]));
AssertJsonErrorCode("foreground_not_target");
}

[TestMethod]
public async Task Focus_NoSelector_TargetWithoutWindow_Fails()
{
_fakeTargetResolver.TargetResult.WindowHandle = 0;

Assert.AreEqual(1, await ParseAndInvokeWithCaptureAsync(
GetRequiredService<UiFocusCommand>(), ["-a", "TestApp", "--json"]));
AssertJsonErrorCode("no_target");
Assert.IsEmpty(_fakeDesktopForeground.ForegroundRequests);
}

[TestMethod]
public async Task Focus_FilterWithoutSelector_IsRejected()
{
ConfigureVerifiedFocus();

Assert.AreEqual(1, await ParseAndInvokeWithCaptureAsync(
GetRequiredService<UiFocusCommand>(), ["-a", "TestApp", "--type", "Edit", "--json"]));
AssertJsonErrorCode("invalid_arguments");
Assert.IsEmpty(_fakeDesktopForeground.ForegroundRequests);
}

[TestMethod]
Expand Down
80 changes: 80 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.MinimizedWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
// Licensed under the MIT License.

using Spectre.Console;
using Spectre.Console.Testing;
using WinApp.Cli.Commands;
using WinApp.Cli.Helpers;

namespace WinApp.Cli.Tests;

public partial class UiCommandTests
{
private async Task<int> RunWithMinimizedTargetAsync(System.CommandLine.Command command, string[] args, bool minimized = true)
{
_fakeTargetResolver.TargetResult.WindowHandle = 4242;
_fakeTargetResolver.TargetResult.WindowTitle = "Calculator";
var previous = UiErrors.s_isWindowMinimized;
UiErrors.s_isWindowMinimized = hwnd => minimized && hwnd == 4242;
try
{
return await ParseAndInvokeWithCaptureAsync(command, args);
}
finally
{
UiErrors.s_isWindowMinimized = previous;
}
}

[TestMethod]
public async Task ElementNotFound_MinimizedTarget_TellsHowToRestoreTheWindow()
{
_fakeUia.FindSingleResult = null;

var exitCode = await RunWithMinimizedTargetAsync(
GetRequiredService<UiInvokeCommand>(), ["num7Button", "-a", "calculator", "--json"]);

Assert.AreEqual(1, exitCode);
AssertJsonErrorCode("element_not_found");
var error = ConsoleStdErr.ToString();
StringAssert.Contains(error, "\"recoveryHint\"");
StringAssert.Contains(error, "is minimized");
StringAssert.Contains(error, "focus -w 4242");
Assert.IsEmpty(_fakeDesktopForeground.RestoreRequests, "A failed lookup must not restore the window itself.");
}

[TestMethod]
public async Task ElementNotFound_VisibleTarget_KeepsSelectorAdvice()
{
_fakeUia.FindSingleResult = null;

var exitCode = await RunWithMinimizedTargetAsync(
GetRequiredService<UiInvokeCommand>(), ["num7Button", "-a", "calculator", "--json"], minimized: false);

Assert.AreEqual(1, exitCode);
Assert.IsFalse(ConsoleStdErr.ToString().Contains("minimized", StringComparison.Ordinal));
}

[TestMethod]
public async Task Search_NoMatchesInMinimizedTarget_WarnsHowToRestore()
{
_fakeUia.SearchResult = [];
var previousAmbient = AnsiConsole.Console;
var ambient = new TestConsole();
AnsiConsole.Console = ambient;
int exitCode;
try
{
exitCode = await RunWithMinimizedTargetAsync(
GetRequiredService<UiSearchCommand>(), ["CalculatorResults", "-a", "calculator"]);
}
finally
{
AnsiConsole.Console = previousAmbient;
}

Assert.AreEqual(1, exitCode);
StringAssert.Contains(ambient.Output, "\"Calculator\" (HWND 4242) is minimized");
StringAssert.Contains(ambient.Output, "focus -w 4242");
}
}
5 changes: 3 additions & 2 deletions src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.SimpleVerbs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,12 @@ public async Task Focus_MissingApp_ReturnsError()
}

[TestMethod]
public async Task Focus_MissingSelector_ReturnsError()
public async Task Focus_EmptySelector_ReturnsError()
{
var command = GetRequiredService<UiFocusCommand>();
var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp"]);
var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["", "-a", "TestApp"]);
Assert.AreEqual(1, exitCode);
StringAssert.Contains(ConsoleStdErr.ToString(), "A selector is required");
}

[TestMethod]
Expand Down
2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, IUiTurn

if (element is null)
{
UiErrors.ElementNotFound(logger, selectorStr, json);
UiErrors.ElementNotFound(logger, selectorStr, json, target: uiTarget);
return 1;
}

Expand Down
2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ private async Task<Endpoint> ResolveEndpointAsync(
var element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken);
if (element is null)
{
UiErrors.ElementNotFound(logger, token, json);
UiErrors.ElementNotFound(logger, token, json, target: uiTarget);
return new Endpoint(false, 0, 0, 0, null, null, null);
}

Expand Down
Loading
Loading