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 Forms | WPF | |
|---|---|---|
| A field's name | the 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 alone | AutomationProperties.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 button | the button | the button: WPF repeats the text in a child, and getByText keeps the outer control |
| A combo box's value | in the tree | read from the control (textContent()); the tree reports none |
| Names that are not the control's | Windows 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:
| Role | What it is |
|---|---|
grid / table | the grid (DataGrid / DataGridView) |
row | a data row; the header row is not one |
cell | a part of a row that holds a value |
columnheader | the header of one column |
header | a 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, ListBox | WPF DataGrid, ListBox, ListView (virtualised) | |
|---|---|---|
| Rows off screen | in the tree, reported off screen | not in the tree until the view reaches them |
| An action on one | a 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 request | the 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'sCellValueChangedruns (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.
- a click selects the cell (
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:
| Control | What works in the background |
|---|---|
TextBox, RichTextBox, MaskedTextBox | fill() 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) |
NumericUpDown | fill() types into its edit, which then takes the number (ValueChanged); its arrows step it through its edit (window.spin) |
DateTimePicker | fill("2026-12-24") (window.date, ValueChanged); its value reads as shown |
TrackBar | fill("30") (ValueChanged), and its value reads |
RadioButton, CheckBox, CheckedListBox | check(), and whether each is checked |
ListView with check boxes | an item selected without opening it (window.select), and checked |
ToolStrip button, drop-down button, MenuStrip | clicked, opened, and their items chosen; each item is reported once; a CheckOnClick button's checked state (read through MSAA) |
ContextMenuStrip | opened with a right click, posted to the control behind another window, and its item chosen |
| Any control | a 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 CheckOnClick | getByRole("button", { name: "Bold" }); its state read through MSAA | getByRole("checkbox", { name: "Bold" }); its state is UI Automation's |
PropertyGrid | a table: getByRole("cell", { name: "Name" }) | categories and properties as tree items, each holding its value: getByRole("treeitem", { name: "Name" }) |
FolderBrowserDialog | the 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 rows | named "Row 0" … | named "Row 1" … |
NumericUpDown arrows | enabled | reported 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
windowinside its container and acted on while another is active. The "Window" menu's list is read once the menu is open (aMenuStrip'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
NotifyIconin the tray, throughdesktop.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 withlaunch({ target: { processName }, windowTimeout: 0 }). PropertyGrid(a property set by name),MonthCalendarand aDateTimePicker'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) andToolStripDropDownButton, aSplitContainerwhose splitter is dragged withapp.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; aRibbonComboBox, a menu bar to UI Automation, is reported as the combo box it is and driven byselectOption.
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):
| Operation | Key | Why |
|---|---|---|
expand() / collapse() on a tree item without the ExpandCollapse pattern | Right / Left arrow | Tree 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 pattern | F4, 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 background | its scroll pattern, a page at a time, through the helper | the 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.