Locators
Locators identify UI elements on the device. They are exposed as Playwright-style getBy* methods on Device and ElementHandle. Each method returns an ElementHandle — a lazy reference that resolves when an action or assertion runs against it.
See the Locators Guide for a deeper discussion of when to use each one.
device.getByText(text: string | RegExp, options?: { exact?: boolean }): ElementHandle
Section titled “device.getByText(text: string | RegExp, options?: { exact?: boolean }): ElementHandle”Locate an element by its visible text. Substring match by default, like Playwright. Pass { exact: true } for an exact match, or a regular expression to test the text with.
device.getByText("Welcome") // substringdevice.getByText("Sign In", { exact: true }) // exactdevice.getByText(/^welcome to\sexpo$/i) // RegExpWhitespace is normalized on both sides before comparing a string, exact or not, as in Playwright: runs of whitespace — including non-breaking spaces ( , U+00A0), other Unicode spaces and line breaks — collapse to one space, and leading and trailing whitespace is ignored. So getByText("Welcome to Expo", { exact: true }) matches text rendered as Welcome to Expo or split over two lines. The same applies to getByRole’s name and to getByLabel. String matching is case-sensitive.
Regular expressions
Section titled “Regular expressions”getByText, getByLabel and getByRole’s name accept a RegExp, as in Playwright. It is matched on the device with JavaScript’s semantics, so it behaves the way it would in a browser:
\sis JavaScript’s whitespace, including non-breaking and other Unicode spaces:/Welcome to\sExpo/matchesWelcome to Expo. A literal space in the pattern matches only a space.\d,\wand\bare ASCII, as in JavaScript.- The text is tested as it is, the way Playwright tests an element’s text:
getByTextandgetByLabeldon’t normalize whitespace first, so^and$anchor the whole text, and a line break needs\sor themflag (/^Line two$/m). AgetByRolename is whitespace-normalized first, as Playwright normalizes accessible names. - Flags:
i,m,sanduwork as in JavaScript;ganddare accepted and have no effect.yandvthrow aTypeErrorwhen the locator is built. { exact: true }is ignored with a RegExp, as in Playwright.- A lookbehind must have a bounded length (no
*,+,{n,}or backreference inside(?<=…)/(?<!…)) — the device regex engines can’t run an unbounded one, so it throws aTypeError. - On iOS, a control that merges its children’s text into one label joined by
", "is tested as that whole label. - A few corners follow the device engine rather than JavaScript:
.and quantifiers count whole characters (code points) even without theuflag, so/^.$/matches one emoji; a backreference to a group that didn’t take part in the match fails instead of matching empty; andialso folds a few special characters (such asſand the Kelvin signK) that JavaScript keeps distinct.
device.getByText(/\d+ items? in cart/)device.getByRole("button", { name: /^save( draft)?$/i })device.getByLabel(/e-?mail/i)Any other value (a number, null, …) throws a TypeError.
Because the default is a substring match,
getByText("Sign in")also matches longer text like"Sign in to continue". When that happens, acting on the locator throws a strict mode violation — add{ exact: true }or usegetByRole(role, { name, exact: true })to pin a single element.
device.getByRole(role: string, options?): ElementHandle
Section titled “device.getByRole(role: string, options?): ElementHandle”Locate an element by its accessibility role, optionally filtered by accessible name or state.
device.getByRole("button", { name: "Submit" })device.getByRole("button", { name: "sign in" }) // also matches "SIGN IN", "Sign in now"device.getByRole("button", { name: "Sign In", exact: true }) // only "Sign In"device.getByRole("textfield", { name: "Email" })device.getByRole("checkbox")device.getByRole("switch", { name: "Dark Mode", checked: true })device.getByRole("button", { name: "Submit", disabled: true })device.getByRole("tab", { name: "Settings", selected: true })device.getByRole("button", { name: "Details", expanded: true })Options:
| Option | Type | Description |
|---|---|---|
name |
string | RegExp |
Filter by accessible name. Case-insensitive substring match by default, like Playwright; whitespace is normalized like getByText. A RegExp is tested against the whitespace-normalized name |
exact |
boolean |
Match a string name case-sensitively and as the whole string (whitespace still normalized). Ignored without name, and for a RegExp |
checked |
boolean |
Filter by checked state (checkbox, switch, radio) |
disabled |
boolean |
Filter by disabled state |
selected |
boolean |
Filter by selected state (tab, option) |
expanded |
boolean |
Filter by expanded state (accordion, dropdown) |
role is one of the supported roles (case-insensitive; header, slider, search and radio are aliases for heading, seekbar, searchfield and radiobutton). Any other role throws when the locator is built, listing the supported roles:
Unknown role "row". Supported: alert, button, checkbox, combobox, heading, image, link, list, listitem, menu, menubar, menuitem, progressbar, radiobutton, radiogroup, scrollbar, scrollview, searchfield, seekbar, spinbutton, spinner, summary, switch, tab, tablist, text, textfield, timer, toolbar (aliases: header → heading, slider → seekbar, search → searchfield, radio → radiobutton). For an element with no supported role, use getByText(), getByTestId() or getByDescription().The accessible name is the element’s content description / accessibility label, its text, or — on Android — the joined text of its descendants. Because the default is a case-insensitive substring match, a name can match more than one element ({ name: "Show overlay" } also matches Show overlay briefly); acting on such a locator throws a strict mode violation — add exact: true. The case-insensitive default also covers platform casing differences, such as React Native’s <Button title="Sign In">, which Android renders as SIGN IN.
device.getByDescription(text: string): ElementHandle
Section titled “device.getByDescription(text: string): ElementHandle”Locate an element by its accessibility description (Android contentDescription, iOS accessibilityLabel).
device.getByDescription("Close menu")device.getByDescription("Profile photo")device.getByPlaceholder(text: string): ElementHandle
Section titled “device.getByPlaceholder(text: string): ElementHandle”Locate an input by its placeholder / hint text.
device.getByPlaceholder("Enter your email")device.getByPlaceholder("Search")device.getByTestId(testId: string): ElementHandle
Section titled “device.getByTestId(testId: string): ElementHandle”Locate an element by its dedicated test identifier.
device.getByTestId("submit-button")On Android the value is compared with the element’s whole resource ID, which is what a React Native testID becomes. A native view’s package-qualified ID (com.myapp:id/submit) is addressed with device.locator({ id: "com.myapp:id/submit" }) instead.
device.getByLabel(text: string | RegExp): ElementHandle
Section titled “device.getByLabel(text: string | RegExp): ElementHandle”Locate an input element by its associated label text. Finds form controls (text fields, checkboxes, switches, etc.) whose accessible name matches the label.
- Android: matches inputs (text fields, checkboxes, switches, radio buttons, seek bars and spinners) whose
contentDescriptionequals the text, or inputs linked vialabelFor/labeledBy. - iOS: matches input elements (text fields, text views, switches, sliders, steppers, pickers, checkboxes and radio buttons, but not search fields) whose
accessibilityLabelor title equals the text.
The comparison normalizes whitespace like getByText. A RegExp is tested against the label text instead.
device.getByLabel("Email") // finds the email text fielddevice.getByLabel(/^e-?mail$/i) // RegExpdevice.getByLabel("Dark Mode") // finds the Dark Mode switchdevice.getByLabel("Volume") // finds the Volume sliderdevice.locator(options: LocatorOptions): ElementHandle
Section titled “device.locator(options: LocatorOptions): ElementHandle”Escape hatch for native, non-accessible queries. Exactly one of id, xpath, or className must be set.
device.locator({ id: "com.myapp:id/email_input" })device.locator({ className: "com.myapp.widget.ColorPicker" })// XPath is Android-only. Always include a comment explaining why.device.locator({ xpath: "//android.widget.Button[@text='OK']" })LocatorOptions:
| Option | Type | Description |
|---|---|---|
id |
string |
Native resource id (Android R.id.foo or iOS accessibilityIdentifier). |
xpath |
string |
XPath expression. Android-only, and for use on its own. and()/or() refuse an xpath operand with an error (its text and bounds come from a different agent read and could never match the other side). Narrowing (filter(), first()/nth()) still queries, but actions through a narrowed xpath locator fail at the agent — it cannot act on an xpath match by id. |
className |
string |
Native widget class name. |
The
getBy*methods andlocator()are also available on everyElementHandle. Calling them on a parent locator scopes the search to its descendants. See ElementHandle Scoping.
iOS wrapper suppression. When traversing the iOS accessibility tree, Tapsmith drops a matching
XCUIElementTypeOthercontainer if a descendant also matches the same locator and shares the wrapper’saccessibilityIdentifier(or, when the wrapper’s identifier is empty, itsaccessibilityLabel). This collapses the redundant wrappers React Native (and SwiftUI in some configurations) emit around interactive elements, so agetByText("Submit")resolves to the actual control rather than the surrounding container. The visible text of the wrapper and descendant is not compared — identifier/label match alone is enough. If your native iOS app deliberately exposes an.othercontainer with the same identifier as a child you also want addressable, the outer wrapper will be silently suppressed in favour of the child — give the wrapper a uniqueaccessibilityIdentifier(or usedevice.locator({ id: ... })) to address it directly.
Strict mode
Section titled “Strict mode”Like Playwright, Tapsmith locators are strict: a locator used for an action, single-element query, or assertion must resolve to exactly one element. When it resolves to more than one, the operation throws a StrictModeViolationError immediately (it does not keep auto-waiting) listing every match with a suggested unambiguous locator:
strict mode violation: getByText("Sign in") resolved to 2 elements: 1) text "Sign in to continue to DreamSpinner" [44,210][436,260] aka device.getByText("Sign in to continue to DreamSpinner", { exact: true }) 2) button "Sign in" [44,640][436,712] aka device.getByRole("button", { name: "Sign in", exact: true })Hint: use { exact: true }, getByRole(role, { name, exact: true }), getByTestId(), or .first()/.nth()/.last() to target a single element.This is the safety net for the substring default of getByText — without it, an ambiguous locator would silently act on the first match in document order, which is rarely the element you meant.
| Operation | Strict? |
|---|---|
Actions (tap, type, scroll, dragTo, setChecked, …) |
Yes |
Single-element queries (find, getText, isVisible, isHidden, boundingBox, scrollIntoView, …) |
Yes |
waitFor({ state: "visible" | "attached" }) |
Yes |
Positive assertions (toBeVisible, toHaveText, toBeChecked, …) |
Yes |
waitFor({ state: "hidden" | "detached" }) |
No — absence is evaluated over all matches |
toBeHidden, not.toBeVisible, not.toExist |
No — absence is evaluated over all matches |
count(), all(), allTextContents(), exists(), toHaveCount |
No — inherently multi-element |
Locators narrowed with .first() / .last() / .nth(n) |
Exempt — they target one match by definition |
To handle a violation programmatically, import the error class or the cross-realm-safe guard:
import { StrictModeViolationError, isStrictModeViolation } from "tapsmith";
try { await device.getByText("Delete").tap();} catch (err) { if (isStrictModeViolation(err)) { console.log(err.elements.length); // the matched ElementInfo objects } throw err;}Accessibility-tree duplicates don’t count. Some platforms expose the same visual element twice — iOS in particular renders a React Native
<Text testID="...">as a parentStaticTextcarrying the attributes plus an inner child with identical text and pixel-identical bounds. Matches with identical text and identical bounds are collapsed to one element (the attribute-carrying first occurrence) before the strict check, since acting on either taps the same point. This collapsing also applies tocount(),all(), and.nth()indexing, so positional chains stay consistent with what you see on screen. Distinct elements that merely share text at different positions still violate.
Transient duplicates: if a screen briefly shows two elements matching the locator mid-transition, the violation throws at that moment (Playwright behaves the same way). Prefer locators that are unique at all times, or
.first()when duplication is expected.
WebView locators too.
webview.getBy*andwebview.locator(css)locators enforce the same rules: actions, single-element queries (isVisible(),textContent(), …), and positive assertions throw on an ambiguous match, whilecount(),all(), absence checks, and.first()/.nth()/.last()chains are exempt. Element descriptions in the violation message come from the DOM (tag, text,id,data-testid,aria-label), and suggestions usewebview.*selectors. For WebView violationserr.elementsholds at most the first 10 matches as sampled descriptions — checkerr.totalCountfor the full match count. The string-selector convenience methods (webview.click(css),webview.fill(css, …), …) predate locators and still act on the first match — preferwebview.locator(css)for strict behavior.

