Skip to main content

WPF and Windows Forms applications

SPART-Desk tests WPF and Windows Forms applications through UI Automation, with the same locators, actions and assertions as any other Windows application. This page is for anyone testing a .NET desktop application: how fields are named, how items are chosen, how grids are read, and how rows far down a list are reached.

import { Desktop, expectDesk } from "@spartaficial/spart-desk";

await using desktop = await Desktop.create();
const app = await desktop.launch({ executable: "C:/Apps/Orders/Orders.exe" });
const main = app.window();

await main.getByLabel("Customer").fill("Ada");
await main.getByLabel("Colour").selectOption("Green");
await main.getByRole("button", { name: "Save" }).click();

const orders = main.getByRole("grid", { name: "Orders" });
const row = orders.getByRole("row").filter({ hasText: "Customer 120", exact: true });
await expectDesk(row.getByRole("cell", { column: "Email" })).toHaveText("customer120@example.com");

Forms​

Windows FormsWPF
A field's namethe label before it in tab order (Windows does this); an open combo box's drop-down list repeats it and is left unnamed, so the label finds the combo box aloneAutomationProperties.Name, LabeledBy or a Label's Target (WPF names the field itself, wherever the label sits); otherwise SPART-Desk names an unnamed field after the text beside it — the nearest on its left, level with it, else the nearest above it, with no other control between — wherever the two are in the tree: labels in one panel and fields in another, or a label beside a date picker, whose text box takes it
getByText("Submit") on a buttonthe buttonthe button: WPF repeats the text in a child, and getByText keeps the outer control
A combo box's valuein the treeread from the control (textContent()); the tree reports none
Names that are not the control'sWindows names a MenuStrip or ToolStrip without an AccessibleName after the label before it in z-order (a status line, say); SPART-Desk drops a bar's name that a label beside it carries, so getByText finds the label alone—

getByLabel never replaces a name the application gave: the inferred name only fills a gap. getByText finds the label, never the field it names: a field is found by text through what it holds. When the text before a field is not its label, name the field in the application, or locate it with getByAutomationId or getByRole("textbox").rightOf(app.window().getByText("Name")).

Choosing an item: selectOption​

selectOption(text) chooses an item of a combo box or a list by its text:

const main = app.window();
await main.getByLabel("Colour").selectOption("Green");
await main.getByRole("list", { name: "Items" }).selectOption("Item 250", { exact: true });

It opens a combo box only when the item is not in the tree yet, chooses the item, and waits until the combo box shows it. WPF takes the item selected through UI Automation. A Windows Forms combo box drops down a native list, whose items are never invoked: their default action double-clicks where the item is, and the second click lands on whatever is under the list once it has closed. SPART-Desk sets the combo box's value to the item's text instead. A combo box left open is closed through its window (CB_SHOWDROPDOWN), found by its class and place, and the operation fails with "its list stays open" rather than leave it open. In a list, the item is selected unless it already is, and the operation waits until the item reports itself selected (UI Automation's IsSelected, which both toolkits report). A Windows Forms ListBox item is selected through the list's window (window.select), since invoking it double-clicks it and runs the list's DoubleClick handler. WPF's items are selected through UI Automation. An item whose whole text is the option wins over items that merely contain it ("Red" over "Dark red"); a pattern is used as given.

Text in a control's template​

WPF leaves a TextBlock inside a control's ControlTemplate out of UI Automation's Control view, which is the view the tree is read in: an in-house banner or badge built as a templated control is one unnamed custom with nothing inside it. For a WPF window, SPART-Desk asks its helper for the text UI Automation's Raw view holds under such a control (at most eight per window, one question per window), and adds it as text controls inside it: exact where reading the screen is not, and found by getByText without a screenshot. Acting on such a text acts where it is drawn.

Text a program lays out in columns without a grid — a panel of rows of TextBlocks — is read as a table when a locator asks for rows or cells, and content painted by a control without an automation peer (a chart, a form) is found in the picture; see resolution.md.

Grids​

WPF's DataGrid and Windows Forms' DataGridView report their parts differently; SPART-Desk gives them the same roles:

RoleWhat it is
grid / tablethe grid (DataGrid / DataGridView)
rowa data row; the header row is not one
cella part of a row that holds a value
columnheaderthe header of one column
headera row's own header, the header row, and the corner above the row headers

A cell is found by its column's header text:

const main = app.window();
const orders = main.getByAutomationId("orders");
await orders.getByRole("row").nth(1).getByRole("cell", { column: "Email" }).textContent();
await orders.getByRole("row").filter({ has: main.getByRole("cell", { column: "Status", name: "Open" }) }).count();

The table API says the same in fewer words, and compiles to these locators (tables.md):

const main = app.window();
const orders = main.table({ root: main.getByAutomationId("orders") });
await orders.row().nth(1).find().then((row) => row.getByColumn("Email").textContent());
await orders.row().where("Status").equals("Open").count();

A row is identified by several of its cells by chaining filters — what a business grid needs, where a name alone repeats and every row is named after its item's type ("Order"). An action on such a locator pages a virtualised grid until the row exists (below):

const main = app.window();
const orders = main.getByRole("grid", { name: "Orders" });
const row = orders
.getByRole("row")
.filter({ has: main.getByRole("cell", { column: "Customer", name: "Acme Ltd", exact: true }) })
.filter({ has: main.getByRole("cell", { column: "Order date", name: "2026-03-14", exact: true }) });
await row.getByRole("cell", { column: "Invoice" }).click();

Start from the grid (orders.getByRole("row")), not from the application: only a locator that scopes into a grid pages it.

Which header a cell sits under is read from the grid's tree: the header over the cell's position on screen, or — for a row a grid reports off screen without bounds — the header at the same place among the row's cells. A column that no grid has is named in the error, with the columns there are.

Rows off screen and rows not created yet​

The two toolkits hold long lists differently, and SPART-Desk reaches both:

Windows Forms DataGridView, ListBoxWPF DataGrid, ListBox, ListView (virtualised)
Rows off screenin the tree, reported off screennot in the tree until the view reaches them
An action on onea grid's cell: paged into view with the grid's scroll bar, through the helper, selecting nothing; a list's item, or a grid without the helper: scrolled into view by selecting it (the grid ignores UI Automation's request)rows in order by what the locator asks for: the view is set to where the row should be through the scroll pattern, and adjusted by what shows; otherwise the grid or list is paged: taken to its top, then a page per attempt until the row appears — with the mouse wheel when the window is in the foreground, with the grid's scroll pattern otherwise (through the helper)
scrollIntoView()the same, on requestthe same, on request

Paging needs to know which grid or list to page, so scope the locator into it: orders.getByRole("row").filter(...), not app.window().getByRole("row"). It takes the container to its top first, so that a row costs as many pages as it is far from the top wherever an earlier step left the view, then pages down to the end, and stops with the reason when the item is nowhere. A container that cannot be scrolled to its top is paged on from where it is.

In the foreground a page is turned with the mouse wheel over the container, which moves nothing but the view. Its first two notches measure how many lines a notch scrolls (WPF's first notch from the top scrolls two, the others three; the larger counts); later pages send as many notches as keep one line of the last view in the next, so no row is skipped. WPF takes a command of several notches as one, so the notches then go one command each, which makes paging with the wheel slow for a row far down. A wheel that does not move the view, or moves it past what was shown, hands over to the scroll pattern, and so does a window that loses the foreground. In the background a page is turned with the container's scroll pattern, through the helper's UI Automation client: exactly what the container calls a page, and it moves neither the selection nor the current item. WPF may bring its window forward as it scrolls, where Windows lets it. A container without a scroll pattern is paged with PageDown, which moves its current item, as a user's PageDown would. Observations and assertions (count(), isVisible(), textContent(), expectDesk(...)) never page or scroll: they report what is there.

Where the rows shown are in order by what the locator asks for — a DataGrid sorted by the column of where(column).equals(value), a ListBox of "Item 1" … "Item 10000" asked for Item 7842 — no page is turned: the view is set to where the row should be through the scroll pattern (as dragging the scroll bar there does, moving neither the selection nor the current item), the grid is looked at alone, and the position is adjusted by what shows until the row is in view. A row thousands down a sorted grid costs a few adjustments this way, not a page per view. A WPF ListBox is asked for an item by its name first, through its ItemContainer pattern, which realizes the item into view in one call; a DataGrid answers no row that way, so its rows keep the jump. Rows that are not in order by that column are paged, and between pages the wait looks at the grid alone, not at the whole window. A row thousands down a grid in no order still takes many pages: prefer the application's own search or filter there, or ask for the row by a column the grid is sorted by.

Windows Forms grids, read through the helper​

A DataGridView reports every row to UI Automation, and a walk of the whole tree visits every one of them. So a locator that reaches into a grid (orders.getByRole("row").filter({ hasText }).getByRole("cell", { column })) would observe the whole window, row by row. The helper reads the grid through its MSAA object instead:

  • Finding the grid. It finds the grid by its AutomationId through UI Automation. A grid named by role and name is searched for once, then read where it was found.

  • Reading it. It lists the rows, and takes each row's cells from the row's value (its cells' values joined by ;).

  • Matching. The locator is matched against the grid as UI Automation reports it: the same rows, cells, column headers and off-screen state.

  • Acting on a cell. Also through the grid's MSAA object, in the background:

    • a click selects the cell (msaa.select);
    • fill() sets its value, and the application's CellValueChanged runs (msaa.set-value);
    • bringing it into view pages the grid with its scroll bar and selects nothing (msaa.scroll-page).

    A double or right click is real input at the cell once it is shown.

The rest of the window. A window that holds a Windows Forms grid of more than 200 rows is read through the helper's own UI Automation tree, which leaves out the grid's rows and fills them in from this reader. getByText of a value, a status line, or any other locator of that window then does not walk every row.

Where the helper is not used. Without the helper (a runtime of one's own that brings none), and for a WPF grid (which pages instead), the tree is observed as a whole, and locators that need the whole window walk every row. These help:

  • Locators with a single step and literal text (getByRole("button", { name: "Save" }), getByLabel, getByAutomationId) search rather than observe the whole tree.
  • A grid on a tab that is not selected is not in the tree at all.
  • Build the application for .NET Framework 4.7.2 or later (Visual Studio does): the runtime then gives grid cells AutomationIds and real bounds. An assembly that names no target framework gets the legacy behaviour, and the helper reads its grid just the same.

A caution for anyone driving such a grid through UI Automation directly: do not ask the Grid pattern for a cell of a Windows Forms grid (GetItem). In a .NET Framework 4.8 application the call brings the application down. SPART-Desk reads cells through MSAA instead.

Windows Forms' standard controls in the background​

With another window in front, each of the standard controls takes its action as a user's, and the application raises its event:

ControlWhat works in the background
TextBox, RichTextBox, MaskedTextBoxfill() and pressSequentially() type into the control's window (window.type): the mask applies, TextChanged runs
ComboBox (drop-down list, editable, ToolStripComboBox)selectOption(), then SelectedIndexChanged once (window.notify); fill() types into an editable one (TextUpdate)
NumericUpDownfill() types into its edit, which then takes the number (ValueChanged); its arrows step it through its edit (window.spin)
DateTimePickerfill("2026-12-24") (window.date, ValueChanged); its value reads as shown
TrackBarfill("30") (ValueChanged), and its value reads
RadioButton, CheckBox, CheckedListBoxcheck(), and whether each is checked
ListView with check boxesan item selected without opening it (window.select), and checked
ToolStrip button, drop-down button, MenuStripclicked, opened, and their items chosen; each item is reported once; a CheckOnClick button's checked state (read through MSAA)
ContextMenuStripopened with a right click, posted to the control behind another window, and its item chosen
Any controla click or double click is posted to its window when another window is in front (window.click, window.dblclick)

A chord (a menu strip's shortcut such as Ctrl+S, or a key the form handles with KeyPreview) is posted to the control's window with its modifiers held in the form's key state, in the background (window.key).

Still needing the foreground or real input: hover and scroll({ wheel }), which are the real mouse over the control's middle (on a grid's cell read through the helper as well; the window is brought to the front for them), and focus() of a control whose form is not its thread's active window (focusing it would activate the form).

Windows Forms on .NET 8 and later​

The same Windows Forms application reads differently to UI Automation on .NET 8 and later than on the .NET Framework: its controls have UI Automation providers of their own instead of answering through MSAA. The engine handles the differences itself; a few change what a test names:

Control.NET Framework.NET 8+
ToolStripButton with CheckOnClickgetByRole("button", { name: "Bold" }); its state read through MSAAgetByRole("checkbox", { name: "Bold" }); its state is UI Automation's
PropertyGrida table: getByRole("cell", { name: "Name" })categories and properties as tree items, each holding its value: getByRole("treeitem", { name: "Name" })
FolderBrowserDialogthe folder tree, "Browse For Folder"the file dialog in its folder mode, "Select Folder", opened in the parent of the folder it was given (type the full path, or a name in that parent)
DataGridView rowsnamed "Row 0" …named "Row 1" …
NumericUpDown arrowsenabledreported unavailable by .NET; the engine treats them as their control is. Reach them through the control: getByRole("spinbutton", { name: "Quantity" }).getByRole("button", { name: "Up" })

A DataGridView of thousands of rows is read through the helper on both runtimes. .NET gives the rows it does not show no place, and counts the rows above a row to name it. So the helper finds the rows shown by their area, pages to a row by them, and names rows by their place when the first and last say so.

Also supported​

  • MDI applications. A document is found as a window inside its container and acted on while another is active. The "Window" menu's list is read once the menu is open (a MenuStrip's items exist only while it is open).
  • Topmost windows, and a main window the application closes and makes anew with the same title: the same locators find it before and after.
  • A NotifyIcon in the tray, through desktop.shell(), on the taskbar or behind "Show Hidden Icons"; its menu is a window of the process. An application that shows no window at first is launched with launch({ target: { processName }, windowTimeout: 0 }).
  • PropertyGrid (a property set by name), MonthCalendar and a DateTimePicker's calendar drop-down (a day chosen in either), autocomplete in a text box and a combo box (a suggestion chosen by keys, or with a real click), ToolStripSplitButton (its main part and its menu) and ToolStripDropDownButton, a SplitContainer whose splitter is dragged with app.window().mouse.drag, and a panel that scrolls (its scroll bar's thumb dragged).
  • One toolkit hosted in the other. WPF inside a Windows Forms window (ElementHost) and Windows Forms inside a WPF window (WindowsFormsHost) read as one tree across the seam, and are filled and submitted in the background; a locator scopes to the host (app.window().getByAutomationId("host").getByLabel("Name")).
  • WPF Ribbon (System.Windows.Controls.Ribbon): a tab by name, a group's button, a toggle, the application menu's items and the quick access buttons; a RibbonComboBox, a menu bar to UI Automation, is reported as the combo box it is and driven by selectOption.

Keys instead of clicks​

Some state changes go through the key a user presses, sent as a window message, which works in the background. The key is sent only once the control is confirmed to have the keyboard focus. After a tab is selected, Windows Forms gives the focus back to the tab control, and Right arrow sent there would switch tabs (actions.md):

OperationKeyWhy
expand() / collapse() on a tree item without the ExpandCollapse patternRight / Left arrowTree items that have the pattern are expanded and collapsed through exactly it (the helper's uia-act with expand/collapse); a plain invoke would select them
collapse() on a combo box without the ExpandCollapse patternF4, or CB_SHOWDROPDOWN to a native combo box that stays open (window.collapse)a plain invoke only ever opens a combo box
paging a virtualised list in the backgroundits scroll pattern, a page at a time, through the helperthe mouse wheel, used in the foreground, needs real input; PageDown needs the focus and moves the current item

Enter, Tab and Escape in the background​

A Windows Forms form gives Enter, Tab and Escape to the control that has the keyboard focus, or to its default button: the AcceptButton, or the button focused last. fill() types into a field in the background without giving it the focus. So press("Enter") on that field, after a button was clicked, can press that button again instead of reaching the field's own KeyDown.

SPART-Desk gives the field the focus first when its window is its thread's active one. It cannot do that in the background, and the action's record says where the key went. Press the field's button (getByRole("button", { name: "Go" }).click()), or click into the field first, which brings the window to the front, as a user does.