Win32 and MFC applications
SPART-Desk tests classic Win32 applications, MFC and hand-written dialogs alike, with the same locators, actions and assertions as any other Windows application, and with another window in front. Windows' own controls tell UI Automation most of what they are. Where they do not (a radio button's state, a date picker's date, a window's menu), SPART-Desk asks the control's own window through its helper executable, and acts on it the way a user's input does. The application then hears each action.
It is verified on dialogs of common controls with resource ids, as an MFC
CDialog is built, on MFC applications of Windows such as System Information
(msinfo32.exe), and on RichEdit 2.0 fields (RichEdit20W, riched20.dll) as
a CRichEditCtrl makes them, which read as written and whose message map
hears what is typed.
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("Name:").fill("Ada"); // typed into the edit's own window
await main.getByLabel("Colour:").selectOption("Blue"); // the dialog hears CBN_SELCHANGE
await main.getByRole("radio", { name: "Large" }).check();
await main.getByLabel("Delivery date:").fill("2026-12-24"); // the picker announces the change
await main.getByRole("menuitem", { name: "View" }).click(); // read, not shown
await main.getByRole("menuitem", { name: "Status bar" }).click(); // WM_COMMAND to the window
await expectDesk(main.getByRole("status").getByRole("text")).toHaveText("Status bar: off");
What each control does, in the background
| Control | Found by | State | Actions |
|---|---|---|---|
| Edit, RichEdit | the IDC_STATIC label before it (getByLabel), or its resource id (getByAutomationId("1001")) | value | fill() and pressSequentially() type into its window (window.type); press() posts the key (window.key) |
| Combo box, drop-down list | its label | value, expanded | selectOption() sets it, then tells the dialog (window.notify, CBN_SELCHANGE) |
| Combo box, editable | its label (its own edit, which repeats the label, is left out) | value | fill() types into its edit (CBN_EDITCHANGE); selectOption() also selects the item of that text |
| Check box, radio button | its text | checked (a radio button's through its window) | check(), click() |
| Up-down control and buddy edit | the buddy's label, which names the up-down too (getByRole("spinbutton", { name })); getByLabel() finds the buddy alone | the buddy's value | fill() on either types into the buddy; press() on the up-down goes to the buddy; its arrows step it (window.spin) |
| Trackbar, progress bar | their labels | value (their position, read through their windows) | fill("30") on a trackbar, whose parent is then told it moved (WM_HSCROLL/WM_VSCROLL), as a drag tells it |
| Status bar | getByRole("status") | its parts' texts, joined: the bar itself holds none | — |
| Group box | its caption, as group; the radio buttons and check boxes inside its frame are its own (group.getByRole("radio")), though Windows makes them its siblings | — | — |
Date picker (SysDateTimePick32) | its label; role combobox | value (the date it shows) | fill("2026-12-24") or fill("2026-12-24T09:30") (window.date) |
| Toolbar buttons, check buttons | their text | enabled, checked | click(), check() |
| SysLink | the link's text (getByRole("link")) | — | click() (window.link, NM_CLICK) |
| Tab control, tree view (filled as nodes expand) | tab and item text | selected tab, expanded | click(), expand(), collapse() |
| List box (owner-drawn too) | item text | selected | click() and selectOption() select through the list's window, never a double-click (window.select); dblclick() is posted to it (window.dblclick) |
| List view (owner-drawn and virtual too) | item text; each column's text; a report-mode list view with column headers is a table to table(name) and getByRole("table") (tables.md) | selected, checked | read through the list's own messages; click() selects, check() sets its check box, scrollIntoView() scrolls to it without selecting (window.select) |
| The window's menu | item text, without & or the shortcut | enabled, checked | click() on a menu bar item opens its menu, and a click on one of its items sends the command (window.menu); press() of a shortcut the menu lists sends that item's command |
Context menu (TrackPopupMenu) | item text | — | click({ button: "right" }) opens it; click() on its item chooses it (window.menu) |
| Any other control | — | — | a click, double or right click is posted to its window when another window is in front (window.click, window.dblclick) |
Common dialogs (ChooseFont, ChooseColor, FindText), message boxes | their title (getByRole("window", { name: "Font" })) | — | as any dialog |
The window's menu
Windows opens a window's own menu only for the window in the foreground. So SPART-Desk reads the menu instead of opening it:
- Opening a menu. A click on a menu bar item tells the application the
menu opens (
WM_INITMENU,WM_INITMENUPOPUP), so that MFC'sON_UPDATE_COMMAND_UIhandlers check and grey its items. The menu's items then join the application's tree until the next action. - Choosing an item. A click on an item sends its command, as choosing
it does (
WM_COMMANDwith its id). An item that opens a submenu opens it the same way. - Nothing is drawn. The menu does not appear on the screen or in a screenshot.
- Closing. Any other action closes it, as it would close a menu shown.
A menu that is not the window's own (one the application draws itself) is clicked as usual.
Scintilla editors
Notepad++, SciTE and the commit dialogs of TortoiseGit and TortoiseSVN edit text in a Scintilla editor. UI Automation reports it as a pane named with its whole text and no pattern:
- It is a
textboxwhose value is its text. It has no name of its own, so it is found by its role or by a label beside it. fill()andpressSequentially()type into its own window, in the background (window.type). A line end is the Enter key, and the indentation the application adds after it is undone, so the text arrives exactly.- A read-only editor refuses typing with
read_only.
const main = app.window();
const editor = main.getByRole("textbox").first();
await editor.fill("Line one\n indented");
await expectDesk(editor).toHaveValue("Line one\n indented");
await main.getByRole("menuitem", { name: "Edit" }).click();
await main.getByRole("menuitem", { name: "Undo", exact: true }).click();
Shortcuts and context menus
- Shortcuts.
press("Control+Shift+B")looks for the item whose text lists that shortcut (&Status bar\tCtrl+Shift+B) in the window's own menu, and sends its command, in the background. A greyed item's shortcut does nothing, as for a user. A chord no menu lists (one only the accelerator table has, or an edit's Ctrl+A) is posted to the control's window with Ctrl, Shift or Alt held in its thread's key state for the moment, still in the background (window.key). - Context menus. A right click opens the application's context menu, as
real input in front or posted to the control's window behind another. Its
item is then chosen in the menu, as Enter chooses it. (In WinUI, File
Explorer and web pages, which take no posted mouse, a right click behind
another window asks the control for its menu through UI Automation instead,
uia.context-menu.)
const main = app.window();
await main.getByRole("listitem", { name: "Banana" }).click({ button: "right" });
await main.getByRole("menuitem", { name: "Copy" }).click();
Owner-drawn lists
An owner-drawn list box that keeps its strings (LBS_HASSTRINGS), and a
virtual list view (LVS_OWNERDATA) that answers LVN_GETDISPINFO, name
their items in the tree. One that keeps no text does not. Its items are then
named from the text read on the screen, and still selected through the
list's window:
- a list box's item after its text;
- a list view's row after its first column, as UI Automation names one.
const main = app.window();
await main.getByLabel("Planets:").getByRole("listitem", { name: "Mars" }).click();
await main.getByLabel("Orders:").getByRole("listitem", { name: "3", exact: true }).click();
Big list views
A list view is read through its own messages, however many rows it holds. A list locator is answered from them without observing the window:
const main = app.window();
const log = main.getByLabel("Log:");
await log.getByRole("listitem", { name: "Entry 9999", exact: true }).click();
A window that holds a list view of more than 200 items is read through the
helper's own UI Automation tree, which leaves out the list's rows and fills
them in from the list's messages. getByText or a status bar's text beside a
big list then does not walk the list's rows.
List view items are clicked on their first column's text: a list without full-row selection takes a click beside the text, even in the item's own cell, as a click on no item. A double click is posted to the list behind another window, and a right click there selects the item through the list and asks the list for its context menu:
const main = app.window();
await log.getByRole("listitem", { name: "Entry 5", exact: true }).dblclick();
await log.getByRole("listitem", { name: "Entry 7", exact: true }).click({ button: "right" });
await main.getByRole("menuitem", { name: "Copy" }).click();
Resource ids
A control's resource id is its AutomationId: getByAutomationId("1001").
Keep in mind:
- An editable combo box's own edit always has the id 1001, whatever id another control of the dialog has.
- Labels (
IDC_STATIC, -1) have no AutomationId.
Notes
focus()moves the keyboard focus through the window's own thread (window.focus) when the window is already its thread's active window. Otherwise focusing a control activates its window, where Windows lets it, and the action says when the window came to the front. No action above needs the focus.- Hover and
scroll({ wheel })are real input, which needs the foreground and an unlocked session: a tooltip reads the real cursor. They reach a list view's items read through the helper too, at the place the list draws the item; a window behind another is brought to the front for them. A locked session fails them withsession_locked; everything above works on a locked session. - A control the application paints itself, such as charmap's character
grid, is found by reading the screen, behind another window too. A row of
glyphs reads as one word; the text asked for is placed within it
(
getByText("PQR")is the Q). charmap's grid takes a click only with its window in front.