Delphi VCL and C++Builder applications
SPART-Desk tests Delphi VCL and C++Builder applications with the same locators, actions and assertions as any other Windows application. VCL tells UI Automation less than most toolkits do, and SPART-Desk makes up for it by reading the screen where the tree is silent. WinSCP (C++Builder) and HeidiSQL (Delphi) are among the applications it works on this way.
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"); // a painted TLabel, read off the screen
await main.getByLabel("Colour").selectOption("Green"); // the application's OnChange runs
await main.getByRole("button", { name: "Save" }).click();
const orders = main.getByRole("grid"); // a TStringGrid or TDBGrid
const row = orders.getByRole("row").filter({ hasText: "Customer 120", exact: true });
await expectDesk(row.getByRole("cell", { column: "Total" })).toHaveText("840.50");
What the tree holds, and what SPART-Desk adds
| VCL control | UI Automation reports | SPART-Desk |
|---|---|---|
TEdit, TMemo, TComboBox, TCheckBox, TButton, TListView, TTreeView, TPageControl, TStatusBar | the Windows control it wraps, which works as in any Win32 application | — |
TLabel (no window) | nothing: the field beside it is unnamed | reads the label and names the field after it, so getByLabel finds it |
TSpeedButton, TDBNavigator's buttons (no window) | nothing: an empty pane | reads captions; a glyph-only button is found with getByImage |
TStringGrid, TDrawGrid, TDBGrid, third-party grids | an empty pane with a scroll bar | role grid; rows, cells and column headers read off the screen |
TGroupBox, TRadioGroup | a pane named by its caption | role group |
TTreeView's items | tree items with no expand state | the state MSAA reports (STATE_SYSTEM_EXPANDED, COLLAPSED); expand() and collapse() click the item and press the arrow key, as a user does, until it holds |
TListView in report mode | a list with column headers, each item its first column's text alone (UI Automation's list view proxy is chosen by class name, and TListView is a class of its own) | a table to table(name), named by its painted label; its rows and every column read through the list view's own window |
TTrackBar | a slider | its position read through its window; fill() tells its parent it moved, as a drag does |
TUpDown and the TEdit it spins | an unnamed spin box beside the field | named by the field's label; reads the field's value; press() goes to the field |
TVirtualStringTree | an empty pane, unless the application includes VirtualTreeView's accessibility unit | role tree |
MessageDlg (TMessageForm) | the buttons; the message is a painted TLabel | reads the message, inside the dialog |
| Toolbar2000 / TBX toolbars | named toolbars, buttons and menu items | — |
Finding controls
VCL gives every control its window handle as its AutomationId, a number
that changes each time the application runs. getByAutomationId still
matches it, but a test that uses one breaks on the next run, so SPART-Desk
never suggests such an id, and agent snapshots leave it out.
Find controls by what a user reads instead:
| Control | DesktopLocator |
|---|---|
| A field beside a painted label | getByLabel("Customer"), or getByRole("textbox", { name: "Customer" }) |
| A field whose label is not beside it | getByRole("textbox").rightOf(app.window().getByText("Customer")), .below(...) |
| A button, check box, tab, menu item | getByRole("button", { name: "Save" }), as anywhere |
| A speed button's caption | getByText("Save", { exact: true }), or scoped: app.window().getByRole("pane").first().getByText("Save") |
| A speed button that shows only a glyph | getByImage("print.png") (image locators) |
| Text in a message dialog | app.window().getByRole("dialog", { name: "Confirm" }).getByText("Delete order 42?") |
A field takes the name of the text painted on its left in the same row,
or, when there is none, above it in the same column, with no other
control between them. getByText of that label finds the label, not the
field. A name the application gave is never replaced.
Reading is exact text matching with one allowance: text read off the screen
is compared again with 1, I, l and | as one character, 0 and O as
one, and spaces ignored, because the recognizer confuses them in small fonts.
Customer 10 matches a cell read as Customer IO, never one reading
Customer 11.
Acting
| Operation | How | Foreground |
|---|---|---|
fill() on a TEdit, TMemo | uia.set-value; OnChange runs | not needed |
fill() on a data-aware field (TDBEdit, TDBMemo, TDBComboBox) | typed into the field's own window, character by character (window.type), because the data set enters edit mode only on a key press; a value set any other way is shown but never written to the record | not needed |
selectOption() on a TComboBox | the combo box's value is set, its drop-down closed, and its parent told the selection changed (window.notify, CBN_SELCHANGE), so that OnChange runs | not needed |
selectOption() on a TListBox, click() on an item of a TListBox or TListView | the item selected through the list's own window, as a click selects it (window.select); OnClick or OnSelectItem runs, never OnDblClick | not needed |
check() on a TCheckBox | uia.invoke; VCL toggles its own state | not needed |
click() on a TButton, a tab | uia.invoke | not needed |
click() on a speed button or a grid cell | a real click where it is drawn | needed |
close(), minimize(), maximize(), restore() | the title bar's buttons, also in 32-bit processes, whose title bars report the buttons without their AutomationIds | not needed |
The background input goes through SPART-Desk's helper executable. It posts messages to the control's own window, and only while that window still has the class the tree reported.
Grids
A grid the tree reports empty is laid out from the lines it draws and the text read inside it. The roles are the ones WPF and Windows Forms grids get (dotnet-apps.md):
| Role | What it is |
|---|---|
grid | the grid |
header | the header row (the first row), and a row's fixed first cell when it shows something |
columnheader | the header of one column |
row | a data row; it has no name of its own, its cells say what it reads |
cell | a part of a row that shows something |
const main = app.window();
const orders = main.getByRole("grid");
await orders.getByRole("row").nth(0).getByRole("cell", { column: "Email" }).textContent();
await orders.getByRole("row").filter({ hasText: "Customer 60", exact: true }).scrollIntoView();
A row that is not on screen is paged to. The grid goes to its top, then
the part of its scroll bar below the thumb is invoked once per attempt. That
moves the view one page in the background and leaves a TStringGrid's
current cell alone. A TDBGrid moves its current record, as a user's
scrolling does. A page is turned only once the view has been read, so a page
costs a read of the screen (the resolution.screen.interval, one second by
default). For a row far down, use the application's own search or filter.
Reading a grid is reading text: a cell reads what the recognizer read, with the allowance above.
The lines are found with colours a few levels apart taken as one. A header painted with a gradient is one band. A grid Windows stretches to 150 % for a DPI-unaware application keeps its lines, blurred over two pixels. A row striped a shade lighter than the background counts as background.
32-bit applications
Most VCL applications are 32-bit, and SPART-Desk drives them like 64-bit
ones. The windows of a 32-bit process are read by the 32-bit build of the
helper (spart-desk-host-x86.exe), which ships beside the 64-bit one and is
started at the first such window; nothing needs to be configured. Actions
still go through the 64-bit build, which finds a control by its path and
RuntimeId. Without the 32-bit build, the 64-bit one reads every window.
Title bars of 32-bit windows report their buttons without AutomationIds, so window operations find them by their place instead.
The TApplication window
Applications built with Delphi 2006 or earlier show a TApplication window
that owns every form, and so do some later ones (HeidiSQL 12). It has no area
and no user sees it. SPART-Desk leaves it out of app.windows(), the tree
and window locators. A modal form it owns is found like any window.
Scoping locators
A locator observes the whole application, so a small dialog over a large main window costs as much as the main window. Scope locators into the dialog all the same: reading the screen then reads only the dialog.
Notes
- FireMonkey (FMX) draws everything into one window and tells UI Automation nothing: only reading the screen and clicking at coordinates apply to it.
- Third-party grids (DevExpress, TMS, JVCL) take the painted-grid path
when their class name contains
Gridand the tree shows nothing inside them. Grid lines drawn the way their default skins draw them (flat or gradient headers, light lines, striped rows) are recognised; a grid that draws no lines is laid out from its text only. - At 150 % Windows stretches a DPI-unaware VCL window with smoothing. The helper executable tells SPART-Desk so, and the small areas it reads (a panel of speed buttons) are enlarged 3× instead of 2×.