Skip to main content

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 controlUI Automation reportsSPART-Desk
TEdit, TMemo, TComboBox, TCheckBox, TButton, TListView, TTreeView, TPageControl, TStatusBarthe Windows control it wraps, which works as in any Win32 application—
TLabel (no window)nothing: the field beside it is unnamedreads the label and names the field after it, so getByLabel finds it
TSpeedButton, TDBNavigator's buttons (no window)nothing: an empty panereads captions; a glyph-only button is found with getByImage
TStringGrid, TDrawGrid, TDBGrid, third-party gridsan empty pane with a scroll barrole grid; rows, cells and column headers read off the screen
TGroupBox, TRadioGroupa pane named by its captionrole group
TTreeView's itemstree items with no expand statethe 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 modea 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
TTrackBara sliderits position read through its window; fill() tells its parent it moved, as a drag does
TUpDown and the TEdit it spinsan unnamed spin box beside the fieldnamed by the field's label; reads the field's value; press() goes to the field
TVirtualStringTreean empty pane, unless the application includes VirtualTreeView's accessibility unitrole tree
MessageDlg (TMessageForm)the buttons; the message is a painted TLabelreads the message, inside the dialog
Toolbar2000 / TBX toolbarsnamed 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:

ControlDesktopLocator
A field beside a painted labelgetByLabel("Customer"), or getByRole("textbox", { name: "Customer" })
A field whose label is not beside itgetByRole("textbox").rightOf(app.window().getByText("Customer")), .below(...)
A button, check box, tab, menu itemgetByRole("button", { name: "Save" }), as anywhere
A speed button's captiongetByText("Save", { exact: true }), or scoped: app.window().getByRole("pane").first().getByText("Save")
A speed button that shows only a glyphgetByImage("print.png") (image locators)
Text in a message dialogapp.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​

OperationHowForeground
fill() on a TEdit, TMemouia.set-value; OnChange runsnot 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 recordnot needed
selectOption() on a TComboBoxthe combo box's value is set, its drop-down closed, and its parent told the selection changed (window.notify, CBN_SELCHANGE), so that OnChange runsnot needed
selectOption() on a TListBox, click() on an item of a TListBox or TListViewthe item selected through the list's own window, as a click selects it (window.select); OnClick or OnSelectItem runs, never OnDblClicknot needed
check() on a TCheckBoxuia.invoke; VCL toggles its own statenot needed
click() on a TButton, a tabuia.invokenot needed
click() on a speed button or a grid cella real click where it is drawnneeded
close(), minimize(), maximize(), restore()the title bar's buttons, also in 32-bit processes, whose title bars report the buttons without their AutomationIdsnot 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):

RoleWhat it is
gridthe grid
headerthe header row (the first row), and a row's fixed first cell when it shows something
columnheaderthe header of one column
rowa data row; it has no name of its own, its cells say what it reads
cella 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 Grid and 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×.