Locators
A locator names a widget; it does not hold one. Every action and every reader re-resolves from a
fresh getTree, so a locator kept across a re-render still points at the right thing, and a ref
that went stale between resolve and dispatch is retried rather than thrown.
import { expect, launchApp } from "@nativedesktop/test";
const app = await launchApp({ entry: "examples/counter/main.tsx" });
await app.getByTestId("increment-button").click();await expect(app.getByTestId("clicks-label")).toContainText("Clicks: 1");
await app.getByRole("textbox").fill("Grocery run");await app.getByRole("button", { name: "Save" }).click();await expect(app.getByTestId("toast")).not.toBeVisible();
await app.close();Attaching to a host somebody else launched (the shape every scripts/*-drive.ts uses, since the
acceptance gates own the process) gives the same surface:
import { connectApp, expect } from "@nativedesktop/test";
const app = await connectApp(); // reads ND_AUTOMATION_SOCKETawait expect(app.getByTestId("volume-slider")).toHaveValue("20");Finding widgets
Section titled “Finding widgets”| Factory | Matches on |
|---|---|
getByTestId(id) |
the node’s testID, exactly |
getByRole(role, {name?, exact?, checked?, disabled?}) |
the schema-declared automation role, plus its accessible name |
getByText(text, {exact?}) |
the node’s own text |
getByLabel(text, {exact?}) |
the accessible name (label, else text, else a string value) |
getByPlaceholder(text, {exact?}) |
the node’s placeholder |
locator(selector) |
a selector string (below) |
A string match is a case-insensitive substring after whitespace normalisation. Pass exact: true,
or a RegExp, for anything stricter.
Locators chain, and the chain descends: app.getByTestId("sidebar").getByRole("button") finds
buttons inside the sidebar. filter({hasText, has, hasNot}), and(), and first() / last() /
nth(i) narrow the current match set without descending.
Selector strings
Section titled “Selector strings”locator() takes the same parts as a string, joined by >>:
app.locator('role=button[name="Save"] >> nth=1');app.locator("type=Table >> has-text=Ada >> first");app.locator("testid=list >> role=button >> last");Engines: testid=, role=x[name="..."|name=/re/][exact][checked][disabled], text=, label=,
placeholder=, type= (the ND widget type, e.g. type=Button). Refinements: nth=i (negative
counts from the end), first, last, has-text=, has=(...), has-not=(...), and=(...). A
quoted value means an exact match; /re/flags is a regular expression.
Actions and readers
Section titled “Actions and readers”Actions: click, dblclick, rightClick, hover, fill, type, pressSequentially, press,
check, uncheck, setChecked, selectOption, focus, blur, scrollIntoViewIfNeeded,
dragTo, screenshot.
Readers: boundingBox, textContent, inputValue, getAttribute, isVisible, isEnabled,
isChecked, isFocused, count, all, allTextContents, node, ref, and
waitFor({state: "attached" | "visible" | "hidden" | "detached"}).
fill replaces a value the way a user finishing an edit would; type appends, matching the type
RPC. count() and isVisible() take one tree read and never wait; the other readers wait for the
node to exist.
app.keyboard and app.mouse cover what no widget owns: keyboard.press("Meta+A") in Playwright
key names, keyboard.type("hello"), mouse.click(x, y) / dblclick / dragTo in
logical-window-topleft coordinates. Those ride the input-synthesis RPCs, so they answer -32003 on
GTK.
app.cursor (macOS) drives the real system cursor instead. The events are posted at the HID tap,
so the app cannot tell them from a physical mouse: hover tracking, native context menus, drag
sessions and window-server hit testing all see them, where app.mouse’s events only reach the
app’s own queue. A test that should prove what a user sees clicks through app.cursor:
await app.cursor.click(app.getByTestId("agree-check"));await app.cursor.rightClick(app.getByTestId("query-input")); // opens the field's native menuawait app.cursor.drag(app.getByTestId("volume-slider"), { x: 400, y: 120 });await app.cursor.dblclick({ x: 60, y: 200 }); // window-relative logical pointsawait app.cursor.scroll(app.getByTestId("row-scroll"), { dy: -200 });Targets are a locator (its centre) or a window-relative point. move/hover/click/drag pass
through steps intermediate positions (12 by default, 20 for the second half of a drag) so hover
and drag tracking see a path, not a jump. Before the first event the app is brought to the front.
The user’s cursor really moves, so avoid it on a machine someone is using. The events come from the
host binary’s --nd-input helper, which runs as its own responsible process: grant the host binary
Accessibility once in System Settings, or with SIP disabled run it with --nd-grant. An ungranted
binary fails the call with the binary’s path in the message. Gate:
scripts/mac/cursor-drive.ts prints ND_CURSOR_OK.
Actionability
Section titled “Actionability”Before a single-target action a locator resolves every 100ms until the deadline (app.actionTimeout,
5000ms by default, or a per-call {timeout}), then requires the one match to be:
- the only match. Two matches is a
StrictModeError, thrown at once rather than waited out, because a second element is a selector bug and not a timing one. - visible, enabled, and occupying real pixels.
- still in the same rectangle on the next read, so a widget mid-animation is never clicked.
Readers and screenshot skip the stable-frame wait; they only need the node to exist.
A timeout says what it waited for and what was nearby:
locator.click: Timeout 5000ms exceeded.Call log: - waiting for testid=save-btn - resolved 0 elementsNearest candidates in window "Notes" (3): Button role=button text="Save As" testID=save-as visible enabled (12,340 88x28)expect
Section titled “expect”expect(locator) polls until the matcher passes or the deadline expires; .not inverts the
predicate and keeps polling, so .not.toBeVisible() waits for a widget to go away.
toBeVisible, toBeHidden, toBeAttached, toBeEnabled, toBeDisabled, toBeChecked,
toBeFocused, toHaveText, toContainText, toHaveValue, toHaveCount, toHaveAttribute.
expect(value) on anything that is not a locator is a plain, non-polling assertion (toBe,
toEqual, toContain, toMatch, toBeGreaterThan, and the rest), so a drive script needs only
one assertion vocabulary.
What locators do not replace
Section titled “What locators do not replace”waitFor and its sugar (app.waitForText, waitForPresent, waitForGone, waitForValue, plus the
page predicates urlContains / pageTitleContains / pageTextContains) run host-side on the
retained tree at a 50ms tick, with no getTree round trip. Where a script already waits that way,
leave it: it is cheaper than any client-side poll, and it is the only path to the page predicates.
Reach past a locator with app.rpc.call(...) when you need a sub-region of a widget (a table row
band, a tab strip, a row’s trailing button), a widget-level field the tree models but locators do not
(itemCount, a SourceTree’s rows), or a webview RPC. locator.ref() and locator.node() hand you
the resolved ref and node for exactly that.
Host methods a locator needs
Section titled “Host methods a locator needs”focus, scrollIntoView, snapshotNode and setWindowFrame are separate RPCs. Against a host that
predates one, the call fails with host predates the "<method>" RPC rather than a bare -32601. The
optional node fields (checked, label, placeholder, options, selected, expanded) are null
on a node the field does not apply to and on a host that predates them, and each reader falls back to
text and value there, so a selector behaves the same either way.
app.setWindowSize(w, h) (and app.setWindowFrame({x, y, width, height}) for a move) answers the
window’s updated WindowInfo, whose geometry is the same {x, y, w, h} a node carries.
locator.screenshot() renders that one node through snapshotNode; pass a path or let the host
write beside the automation socket and tell you where.
Acceptance
Section titled “Acceptance”scripts/locator-drive.ts against examples/locators is the gate for this surface: focus() plus
toBeFocused, press("Meta+a"), scrollIntoViewIfNeeded() on a clipped row, a node-sized
screenshot(), setWindowSize with the root’s boundingBox() following, isChecked() across
check()/uncheck(), and selectOption("Downloads") by label. It prints ND_LOCATOR_OK and runs
as the third leg of scripts/mac/mac-gestures.sh.