Skip to main content

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​

ControlFound byStateActions
Edit, RichEditthe IDC_STATIC label before it (getByLabel), or its resource id (getByAutomationId("1001"))valuefill() and pressSequentially() type into its window (window.type); press() posts the key (window.key)
Combo box, drop-down listits labelvalue, expandedselectOption() sets it, then tells the dialog (window.notify, CBN_SELCHANGE)
Combo box, editableits label (its own edit, which repeats the label, is left out)valuefill() types into its edit (CBN_EDITCHANGE); selectOption() also selects the item of that text
Check box, radio buttonits textchecked (a radio button's through its window)check(), click()
Up-down control and buddy editthe buddy's label, which names the up-down too (getByRole("spinbutton", { name })); getByLabel() finds the buddy alonethe buddy's valuefill() on either types into the buddy; press() on the up-down goes to the buddy; its arrows step it (window.spin)
Trackbar, progress bartheir labelsvalue (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 bargetByRole("status")its parts' texts, joined: the bar itself holds none—
Group boxits 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 comboboxvalue (the date it shows)fill("2026-12-24") or fill("2026-12-24T09:30") (window.date)
Toolbar buttons, check buttonstheir textenabled, checkedclick(), check()
SysLinkthe link's text (getByRole("link"))—click() (window.link, NM_CLICK)
Tab control, tree view (filled as nodes expand)tab and item textselected tab, expandedclick(), expand(), collapse()
List box (owner-drawn too)item textselectedclick() 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, checkedread through the list's own messages; click() selects, check() sets its check box, scrollIntoView() scrolls to it without selecting (window.select)
The window's menuitem text, without & or the shortcutenabled, checkedclick() 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 boxestheir 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's ON_UPDATE_COMMAND_UI handlers 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_COMMAND with 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 textbox whose 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() and pressSequentially() 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 with session_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.