ElementHandle
An ElementHandle is a lazy reference to a UI element. It is returned by every device.getBy*() and device.locator() call, and supports chaining, queries, actions, and positional selection.
Scoping
Section titled “Scoping”ElementHandle exposes the same getBy* methods and locator() as Device. Calling any of them on an existing handle scopes the search to the elements inside it, like Playwright’s locator.locator(...). “Inside” means on screen: the child’s centre lies within the parent’s bounds, and it is not a larger element around the parent (an ancestor, a backdrop behind a dialog). On iOS the parent itself matches when nothing inside it does: an accessible React Native container, such as a Pressable, is one element whose children are not exposed, so card.getByText("Title") can only match the card. Android exposes the children, so there the parent never matches. Elements scrolled out of the parent’s bounds are not inside it. Bounds are used rather than the accessibility tree’s parent/child links because the trees React Native produces often make a container’s children its siblings (a modal’s buttons are not children of its testID view). Bounds cannot see what is in front: an element drawn over the parent from elsewhere (a toast, a floating button) is inside it too, and so is content underneath a parent that is itself an overlay, such as a non-modal bottom sheet or menu over a screen with a same-named control. Narrow such a locator further (a name, getByTestId()). The one edge in front that scoping does see is a native iOS alert’s: the alert’s contents are in its scope and nothing behind it is, so alert.getByRole("button") finds the alert’s own buttons even while a React Native app’s views stay in the tree behind it, and an app container behind the alert does not hold the alert’s buttons. filter({ has }) and filter({ hasNot }) draw the same line.
| Method | Description |
|---|---|
getByText(text, options?) |
Case-insensitive substring (default), exact or RegExp text match within the parent. |
getByRole(role, options?) |
Accessibility role within the parent. |
getByDescription(text) |
Accessibility description within the parent. |
getByPlaceholder(text) |
Placeholder / hint text within the parent. |
getByTestId(id) |
Test identifier within the parent. |
getByLabel(text) |
Input element by associated label text within the parent. |
locator(options) |
Native id / xpath / className within the parent. |
Scoping also works after a positional or filtering modifier (.first(), .last(), .nth(), .filter(), .and(), .or()), just like Playwright. The parent is resolved to its concrete element(s), which must report bounds. A positional parent (.first()/.nth()) scopes to its single selected element; a plain or filtering parent scopes to every match it resolves to (a child inside any of them is in scope).
const list = device.getByTestId("cart-list"); // e.g. a FlatList with testID="cart-list"const item = list.getByText("Item 3", { exact: true });await item.tap();
// Tap a delete button inside a specific rowawait device.getByTestId("row-5").getByRole("button", { name: "Delete" }).tap();
// Scope into a positional match — the submit button inside the first dialogawait device.getByTestId("dialog").first().getByRole("button", { name: "Submit" }).tap();Positional Selection
Section titled “Positional Selection”elementHandle.first(): ElementHandle
Section titled “elementHandle.first(): ElementHandle”Return a new handle targeting the first match. The handle is lazy – it does not resolve until an action or assertion is performed.
await device.getByRole("listitem").first().tap();elementHandle.last(): ElementHandle
Section titled “elementHandle.last(): ElementHandle”Return a new handle targeting the last match.
await device.getByRole("listitem").last().tap();elementHandle.nth(index: number): ElementHandle
Section titled “elementHandle.nth(index: number): ElementHandle”Return a new handle targeting the match at the given 0-based index. Negative indices count from the end. The index must be an integer — nth(1.5) throws.
Chaining a positional modifier onto an already-positional handle narrows that one element rather than re-indexing the original match set, as in Playwright (and as WebViewLocator does): list.nth(2).first() is list.nth(2), while list.first().nth(1) matches nothing. A filter() chained after the index applies to that one element too — see all().
await device.getByRole("listitem").nth(2).tap();await device.getByRole("listitem").nth(-1).tap(); // last itemFiltering
Section titled “Filtering”elementHandle.filter(criteria: FilterOptions): ElementHandle
Section titled “elementHandle.filter(criteria: FilterOptions): ElementHandle”Narrow matches by additional criteria without changing the locator. Returns a new lazy handle.
const premiumItems = device.getByRole("listitem").filter({ hasText: "Premium" });const count = await premiumItems.count();FilterOptions:
| Option | Type | Description |
|---|---|---|
hasText |
string | RegExp |
Keep elements whose text contains this string or matches this RegExp. An element with no text of its own, such as a list row, has its descendants’ text, as for toContainText |
hasNotText |
string | RegExp |
Exclude elements whose text contains this string or matches this RegExp |
has |
ElementHandle |
Keep elements that have a descendant matching this locator |
hasNot |
ElementHandle |
Exclude elements that have a descendant matching this locator |
Combining Locators
Section titled “Combining Locators”elementHandle.and(other: ElementHandle): ElementHandle
Section titled “elementHandle.and(other: ElementHandle): ElementHandle”Return a handle matching elements that satisfy both this and the other handle’s locator (intersection). AND binds tighter than OR.
const submitButton = device.getByRole("button").and(device.getByDescription("Submit"));await submitButton.tap();elementHandle.or(other: ElementHandle): ElementHandle
Section titled “elementHandle.or(other: ElementHandle): ElementHandle”Return a handle matching elements that satisfy either this or the other handle’s locator (union).
const acceptButton = device.getByText("OK", { exact: true }).or(device.getByText("Accept", { exact: true }));await acceptButton.tap();How operands are matched. Each operand is resolved with its own hierarchy read, and the on-device agents give an element a fresh internal id on every read, so the two reads are combined by the element’s visual identity — its bounds and its text — rather than by id. and() keeps the left operand’s matches whose bounds and text also appear in the right operand’s; or() keeps every left match and adds the right operand’s matches that the left did not already have; the union is then ordered by position on screen (top, then left — matches without usable geometry come last, in operand order), so first() on a union is the topmost match, as with Playwright’s DOM-ordered or(). A positional modifier on an operand applies to that operand first: a.first().and(b) intersects the first a with b. An xpath locator is refused as an operand (and()/or() throw), since on Android its text and bounds come from a different agent read and could never match the other side. Two distinct elements that share both bounds and text (a pressable and the single text it wraps, with no padding) count as the same target, which is also how strict mode already treats them; acting on either taps the same point. An element with no usable geometry — scrolled fully out of view, which Android reports as a zero-size rectangle — has no cross-read identity: and() does not match it until it is on screen (scrollIntoView() keeps scrolling; toBeVisible() is false either way), and or() keeps each operand’s copy of it, so a single-element use of an overlapping union (including scrollIntoView()) fails with a strict-mode violation while the shared element is fully off-screen — bring it on screen through one operand first, or use a single-selector locator for scrolling. If the screen moves — or a label’s text changes — between the two reads, that and() tick simply misses; auto-waiting readers (find(), actions, expect(...)) retry it, while the one-shot readers count() and exists() report that tick as it was, and an absence assertion (toHaveCount(0), toBeHidden(), not.toBeVisible()) accepts it as its answer; an or() whose operands both match the moving element can see it twice for that tick and raise a strict-mode violation on a single-element action, so wait for the transition to settle before acting through an overlapping union. Both operands must also resolve the same node (or same-frame nodes): a role query resolving a row container and a text query resolving a differently-framed inner label do not intersect, so prefer operand pairs that target the same element, as the built-in role/text queries do.
Queries
Section titled “Queries”elementHandle.find(options?): Promise<ElementInfo>
Section titled “elementHandle.find(options?): Promise<ElementInfo>”Resolve the handle to an ElementInfo object. Throws if the element is not found within the timeout: the device timeout, or the per-call timeout (find({ timeout: 2000 })).
The ElementInfo object contains:
| Property | Type | Description |
|---|---|---|
elementId |
string |
Internal element identifier |
className |
string |
Android class name |
text |
string |
Visible text content |
contentDescription |
string |
Accessibility content description |
resourceId |
string |
Android resource ID |
enabled |
boolean |
Whether the element is enabled |
visible |
boolean |
Whether the element is visible |
clickable |
boolean |
Whether the element is clickable |
focusable |
boolean |
Whether the element is focusable |
scrollable |
boolean |
Whether the element is scrollable |
hint |
string |
Input hint text |
checked |
boolean |
Whether the element is checked |
selected |
boolean |
Whether the element is selected |
focused |
boolean |
Whether the element has input focus |
role |
string |
Accessibility role (e.g. “button”, “textfield”) |
viewportRatio |
number |
Fraction of element visible in viewport (0.0-1.0) |
inAlert |
boolean | undefined |
iOS: the element is a native alert or inside one. Always false on Android |
bounds |
Bounds |
Element bounding rectangle |
elementHandle.exists(): Promise<boolean>
Section titled “elementHandle.exists(): Promise<boolean>”Returns true if the element exists in the UI hierarchy right now, whether or not it is visible.
Like a Playwright presence check (Playwright has no exists(); locator.count() > 0 is the idiom
there), this does not wait for the element to appear. It reads the current hierarchy and returns
false without waiting for it when nothing matches (two reads and a short idle wait, see below), so it
is safe to branch on presence. To wait for an element, use expect(locator).toExist() or waitFor({ state: "attached" }).
Both of those are strict (see below), so if the locator may match several elements, narrow it with
.first() first or wait on expect(locator).toHaveCount(n).
const exists = await device.getByText("Optional banner", { exact: true }).exists();
// Presence branch — answers at once, no timeout is spent on a missing elementconst logOut = device.getByRole("button", { name: "Log out" });if (await logOut.exists()) { await logOut.tap();}exists() is exempt from strict mode, like count() and all(): a locator that matches several
elements answers true rather than throwing. That is the difference from isVisible(), which is a
strict single-element query and also requires the match to be visible. An attached but off-screen or
invisible element exists.
The reliability contract is the same as isVisible(): a present element costs one hierarchy read; an
absent one costs two (the first empty read is confirmed after waiting for the UI to settle, at most
1.5 s); a stale mid-re-render snapshot is re-read; a momentary agent fault is retried for about two
seconds and then thrown (an agent command timeout, which means the agent is alive but slow, is retried
until the handle’s timeout instead, like an action); and a user stop propagates instead of being reported as “doesn’t exist”.
Infrastructure problems are never reported as an answer. If every read is a stale snapshot (a screen
that never stops re-rendering, with no empty read to fall back on) the call re-reads for the handle’s
timeout and then throws, pointing at expect(locator).toExist() / .not.toExist() and
waitFor({ state: "attached" }).
Behaviour change. Before this release
exists()handed the handle’s timeout to the on-device agent, which waited for the element, so an absent element cost the whole action timeout (30 s by default) beforefalsecame back. It now answers at once. Code that relied onexists()to wait for an element to appear should useawait expect(x).toExist()orx.waitFor({ state: "attached" })— or, for a locator that may match several elements,await expect(x.first()).toExist()orawait expect(x).toHaveCount(n), since the waiting forms are strict andexists()is not.Two further changes affect handles with
.filter(),.and(),.or(), or agetBy*scoped to a modified parent. Those used to reportfalsefor any error, sox.filter({ hasText: "X" }).exists()answeredfalsewhen the filter kept several candidates; it now answerstrue, as an ambiguous locator should for a multi-element query. And a user stop, a transport failure or a persistent device fault now propagates as an error instead of being reported as “doesn’t exist” — including the descriptive error thrown when the hierarchy never settles for the whole timeout, which the unmodified form already threw.
elementHandle.count(): Promise<number>
Section titled “elementHandle.count(): Promise<number>”Return the number of elements the locator matches. Every modifier applies — filter(), and()/or(), scoping and first()/nth()/last() — so locator.first().count() is 1 or 0, as in Playwright.
const itemCount = await device.getByRole("listitem").count();count() does not wait for an element: nothing matching is 0 at once. Like isVisible(), a read that
lands mid-re-render (a stale snapshot) is not treated as an answer but re-read until one lands between
frames, for up to the handle’s timeout — so on a screen that never stops changing the call can take the
full timeout and then throws the stale error. A timeout of 0 in the config makes it a
single read. The same contract
applies to all() and allTextContents().
elementHandle.all(): Promise<ElementHandle[]>
Section titled “elementHandle.all(): Promise<ElementHandle[]>”Return an array of ElementHandle instances, one for each element the locator matches. Useful for iterating over a list of elements. Every modifier applies, as for count(): locator.first().all() (or .nth(i)/.last()) yields the one handle that position names, or an empty array.
// Read every row's text in one hierarchy readconst names = await device.getByRole("listitem").allTextContents();
// Iterate all() when each row needs its own actionfor (const item of await device.getByRole("listitem").all()) { await item.tap(); // each handle is a live nth(i) locator — one read per call}Each returned handle is a plain .nth(i) locator, exactly as in Playwright: nothing is cached, so
every reader (find(), getText(), isVisible(), isEnabled(), boundingBox(), exists(),
count(), …), every action and every expect() on items[i] resolves live against the device.
A check and the action it guards therefore always describe the same element — whatever is at index
i when each of them runs:
const rows = await device.getByRole("listitem").all();await rows[0].tap(); // removes row 0; the list shifts upif (await rows[1].isVisible()) { await rows[1].tap(); // taps the element now at index 1 — the same one isVisible() saw}The flip side is that once the list changes, items[i] names whatever is at index i now, not the
row it named when all() ran — including after items[i].scrollIntoView() on a virtualised list,
where the rendered window shifts under the index. To act on a particular row, prefer a locator that
names it (getByText, filter({ hasText })), or call all() again after the list changes.
Scoped children (items[i].getByRole("button")) resolve their parent the same live way.
Because a handle from all() is an ordinary positional locator, further modifiers compose in call
order, as in Playwright: items[i].filter({ hasText: "Sold out" }) is row i if it matches and
nothing otherwise, items[i].first() is items[i] itself, items[i].nth(1) matches nothing, and
items[i].and(x) / items[i].or(x) use that row as an operand. The same rule applies to any
positional locator: list.nth(1).filter(…) means “row 1 if it matches”, not “the second matching
row” — put the filter first (list.filter(…).nth(1)) for the latter. list.first().all() (or
.nth(i)/.last()) yields that one locator, or an empty array.
Each call on a handle from all() is one hierarchy read, so iterate all() when you need per-row
actions. For a batch read, use a single query instead: allTextContents()
for the texts, count() for the size, or an assertion such as toHaveCount. On an Android emulator a
hierarchy read costs roughly 0.7 s, so for (const row of rows) await row.getText() over ten rendered
rows takes about 7 s where rows.allTextContents() takes one read. Issuing the reads concurrently
(Promise.all(rows.map((row) => row.getText()))) does not help: the same dumps queue on one on-device
agent, and on a loaded emulator they surface as agent command timeouts and stale-snapshot retries
rather than as speed.
Behaviour change. Before this release the handles returned by
all()carried the snapshot they were resolved from:find(),getText(),isEnabled(),boundingBox()and actions answered from that capture (no device read), whileexpect(),waitFor(),isVisible()andcount()re-queried live — so a check and the action it guarded could describe different elements, andfirst(),nth(),filter(),and(),or()andall()threw on such a handle. Every reader now resolves live and those methods compose instead of throwing. Two consequences on upgrade: a per-row loop such asfor (const row of rows) await row.getText()now costs one hierarchy read per row (useallTextContents()for a batch read), andlist.nth(1).filter(…)— on any positional locator, not only one fromall()— now means “row 1 if it matches” rather than “the second matching row”; writelist.filter(…).nth(1)for the old meaning.
elementHandle.allTextContents(): Promise<string[]>
Section titled “elementHandle.allTextContents(): Promise<string[]>”Return the text of every element the locator matches, in match order, from a single hierarchy read
(Playwright’s allTextContents()). Every modifier applies, as for count() and all(), and like them
it does not wait and is exempt from strict mode: when nothing matches it resolves to [].
const names = await device.getByRole("listitem").allTextContents();expect(names).toEqual(["Item 1", "Item 2", "Item 3"]);Waiting
Section titled “Waiting”elementHandle.waitFor(options?): Promise<void>
Section titled “elementHandle.waitFor(options?): Promise<void>”Wait until the element reaches the specified state. Polls the UI hierarchy until the condition is met or the timeout expires.
// Wait for a loading spinner to disappearawait device.getByRole("progressbar").waitFor({ state: "hidden" });
// Wait for an element to appear (default state is 'visible')await device.getByText("Welcome").waitFor();
// Wait for element to be removed from the hierarchy entirelyawait device.getByText("Toast message").waitFor({ state: "detached" });
// Wait for element to exist (even if not visible, e.g. off-screen)await device.getByTestId("lazy-section").waitFor({ state: "attached" });Options:
| Option | Type | Default | Description |
|---|---|---|---|
state |
'visible' | 'hidden' | 'attached' | 'detached' |
'visible' |
Target state to wait for |
timeout |
number |
Device timeout (30s) | Maximum time to wait in milliseconds. 0 (or any number under 1) checks once without waiting |
Unknown options and states throw a TypeError, as for actions.
States:
| State | Condition |
|---|---|
visible |
Element exists in the hierarchy AND is visible |
hidden |
Element doesn’t exist OR exists with visible === false |
attached |
Element exists in the hierarchy (regardless of visibility) |
detached |
Element does not exist in the hierarchy |
Actions
Section titled “Actions”Per-call timeout
Section titled “Per-call timeout”Every action except scrollIntoView() (bounded by its maxScrolls) and highlight(), and every reader that waits for its element (find(), getText(), inputValue(), isEnabled(), isChecked(), isEditable(), boundingBox()), takes a timeout option in milliseconds, as Playwright’s do. The options type is exported as TimeoutOptions (import type { TimeoutOptions } from "tapsmith"). It bounds the whole call — the auto-wait for the element and the action itself — and overrides the device timeout (the config’s timeout, 30 s by default) for that call only. It may be shorter or longer than the device timeout. As with the device timeout, an element found just before the timeout still gets up to a second for the action itself (and setChecked() up to a second to see its state change), so a call can end shortly after the timeout rather than give up on an element it has found. With tracing on, the screenshot and hierarchy captured before the action are not counted either, except by setChecked(), whose check that the state changed runs to the timeout counted from the start of the call.
await device.getByRole("button", { name: "Submit" }).tap({ timeout: 5000 });await device.getByPlaceholder("Email").type("user@example.com", { delay: 50, timeout: 10_000 });const label = await device.getByTestId("status").getText({ timeout: 2000 });The timeout must be a finite number of milliseconds, at least 1 (a value under 1 is refused as most likely meant in seconds). Unlike Playwright, 0 does not mean “no limit”: it is refused, as is Infinity, so pass a long timeout instead.
Options are checked when the call is made, so a mistake fails at once rather than being ignored. Tests usually run without a type check, so TypeScript does not catch these. An unknown option (tap({ timout: 5000 })), an options argument that is not an object (tap(5000)) and an invalid timeout throw a TypeError naming the method and the options it accepts. A Playwright option with no Tapsmith equivalent, such as force, trial or position, gets a message saying so. Options set to undefined are ignored. exists(), count(), all() and allTextContents() take no arguments, since they read the screen as it is: given one (exists({ timeout: 5000 })), they throw rather than answer at once as if they had waited, and the message names the waiting form to use.
elementHandle.tap(options?): Promise<void>
Section titled “elementHandle.tap(options?): Promise<void>”Tap this element.
options.timeout?:number— see Per-call timeout.
await device.getByRole("button", { name: "Submit" }).tap();await device.getByRole("button", { name: "Submit" }).tap({ timeout: 5000 });Covered elements. Like a Playwright click, a tap auto-waits for the element to be enabled and then for nothing to be drawn over it. If something covers the point it would tap, the tap waits for the cover to go away. Covers include the software keyboard, a bar or window drawn over the element, and another control painted on top of it (a button, link, text input, list cell, alert, sheet…). If the cover clears within the action timeout, the tap goes ahead. If it is still there at the timeout, the tap fails instead of tapping the cover: Element is covered by the keyboard … or Element is covered by button "Overlay" …. When the keyboard or a bar covers only part of the element, the tap lands on the visible part. doubleTap(), longPress(), and the tap that type(), clear() and focus() use to focus a field follow the same rules. A field that already has focus is not tapped at all when the keyboard, or a control on the same screen, covers it (typically its own keyboard): typing reaches it anyway. A dialog or other window over the field is waited out like any other cover, since it takes the input. If the element goes away, or turns into a different element, while its cover is being waited out, the action does not tap whatever replaced it: it looks the locator up again and fails as not found if nothing matches. If the keyboard covers an element that is not in a scroll view, dismiss it first (device.hideKeyboard(), or submit the field). A plain view that is not a control (for example an unlabeled backdrop) is not detected as a cover yet.
Moving elements. Like a Playwright click, a tap on iOS waits for the element to be stable before touching it, so a button in a menu, popover or action sheet that is still animating in is not tapped mid-animation (such a tap highlights the button without running its action). It waits for what the element shows on screen to stop changing, since iOS reports an animating element’s final frame from the start. Before that, it waits for the app’s UIKit animations to finish, because iOS drops touches while a screen or modal is being presented or dismissed: a tap on a button in a modal that is still fading in would do nothing. Each of these waits lasts up to a second: an element that keeps changing (a spinner inside a button) is tapped after that rather than failing. React Native’s own animations (Animated, an ActivityIndicator) are not UIKit animations, so a screen that never stops animating doesn’t hold taps up. doubleTap() and longPress() wait the same way. On Android, tap() touches the element its auto-wait found (the one strict mode and the enabled check passed), and waits for its position to hold still across two display frames: its bounds are read again at least 34 ms after the read that found it, and if they moved, the tap waits until two reads 50 ms apart agree, for up to a second. If that element has gone in the meantime (the app re-rendered it), the tap looks the locator up again.
Touch hold. A tap presses and lifts at once on Android, and within one display frame (10 ms) on iOS. React Native cancels a press when a scroll view beside the element moves while the finger is down (a carousel paging itself, a list still settling), so a longer press could report a tap the app never received. On iOS, UIKit controls that only register a touch that spans a display frame are held 20 ms: switches, checkboxes, sliders, steppers, segmented controls, pickers and other control types, anything inside one of them (a segment, a stepper’s increment button) or inside an alert or action sheet, and a touch whose target is unknown. Buttons, texts, cells, images, links, text inputs and plain views (which is what most React Native components are, including their checkboxes) are held 10 ms. doubleTap(), setChecked(), selectOption(), device.tapXY() and the MCP and UI-mode tap tools hold their touches the same way. On an iOS simulator, tap() (and so setChecked()) presses as a real touch through the simulator’s input system, as doubleTap() and longPress() already do, which is a few hundred milliseconds faster a tap than XCTest’s touches; on a physical iPhone it goes through XCTest. Such a touch reaches the app as it happens, so a scroll event could arrive inside even a 10 ms hold: there, a tap(), and each tap of a doubleTap(), that would be held 10 ms is pressed and lifted back to back instead, and a control’s 20 ms hold is kept.
The keyboard. A React Native ScrollView (or FlatList) with the default keyboardShouldPersistTaps spends the first touch after typing on closing the keyboard, and the button under the finger never gets it. So when the software keyboard is up and the element is in a React Native scroll view (a ScrollView on Android, a .scrollView on iOS; native lists, tables and web content don’t count), tap() puts the keyboard away first, then taps the element where it has moved to. doubleTap() and longPress() do the same. A text input, or a control inside one such as its clear button, is tapped with the keyboard left up, so tapping the next field just moves the focus. An element outside a scroll view, such as a row of a native suggestion list, is tapped with the keyboard up too: if the keyboard covers it, the rules above apply. If the keyboard can’t be put away, the tap goes ahead as before.
- Android puts the keyboard away with BACK. The field keeps its focus.
- iOS puts it away by tapping a blank spot beside the field, as the user’s own first tap would have. Where there is no blank spot, or the tap doesn’t close the keyboard, the tap goes ahead with the keyboard up. It never presses the return key, since that would submit the field, and it gives up after about 2.5 seconds. The field loses its focus, as it would have after the swallowed tap. iOS only does this once the test has typed into, focused or tapped a text input, so a keyboard the app opens by itself (
autoFocus) is left alone until then.
The app’s keyboardShouldPersistTaps can’t be seen from outside it, so a scroll view set to "handled" or "always", whose taps would have got through, gets the same treatment. A list that only shows while the keyboard is up or the field has focus (React Native suggestions) can close before its row is tapped: on iOS because the field loses focus, on Android if the list hides when the keyboard does. To tap such a row with the keyboard up, tap its coordinates: const box = (await row.boundingBox())!; await device.tapXY(box.x + box.width / 2, box.y + box.height / 2);. Before Android 11 (API 30), React Native may still swallow the tap while the field has focus, even with the keyboard gone; submit the field from the keyboard (device.pressKey("enter")) there instead.
Off-screen elements. Unlike a Playwright click, a tap does not scroll its element into view. This is by design, so taps behave the same on both platforms: Android’s accessibility tree generally holds only on-screen elements, so a tap there cannot see a row that is scrolled away, let alone scroll to it. Scroll to the element first with scrollIntoView(), then tap it:
const row = device.getByText("Item 40", { exact: true });await row.scrollIntoView(); // a row below the screenawait row.tap();
const first = device.getByText("Item 1", { exact: true });await first.scrollIntoView({ direction: "up" }); // a row the list was scrolled past (iOS finds the way without the direction; Android needs it)await first.tap();An element that is off-screen auto-waits like any other, in case it scrolls or animates into view within the timeout. The same goes for a row its list clips (behind a header above the list), which the device’s agent refuses to touch. If it is still out of view at the timeout, the tap fails with advice to scroll first. For a clipped row, the advice is to move the list with scroll(), since scrollIntoView() counts a clipped row as visible and returns at once. iOS lists off-screen elements in its tree, so it can say for certain: Element … is off-screen: it was found, but no part of it was on screen after waiting 30000ms. tap() does not scroll, so scroll it into view first with await locator.scrollIntoView({ direction: "up" }), then tap() it. (the direction is the one scrollIntoView() needs). Android usually doesn’t list an off-screen element at all, so it says it may be off-screen: Element … was not found after waiting 30000ms. It may be off-screen in a scrollable list: tap() does not scroll, so scroll it into view first with …. doubleTap(), longPress() and setChecked() behave the same; setChecked() does not wait for an off-screen element that is already in the requested state.
On Android, an element that is entirely behind the keyboard is hidden from the accessibility tree. An action on it fails as not found (after waiting out the timeout) rather than as covered, and assertions such as toBeVisible() and toHaveValue() cannot see it until the keyboard is gone.
elementHandle.doubleTap(options?): Promise<void>
Section titled “elementHandle.doubleTap(options?): Promise<void>”Double-tap this element.
options.intervalMs?:number— interval in milliseconds between the two taps. Overrides the globaldoubleTapIntervalconfig for this call. Defaults to100. On an iOS simulator the two taps are sent as real touches about 120 ms apart andintervalMsis ignored, because a fixed gap stays inside the app’s double-tap window however busy the machine is. Where that route is unavailable (a physical iOS device, or a simulator where Tapsmith’s touch helper cannot start), the interval is used, raised to at least 250 ms on iOS 26 and later runtimes when the agent was built with an Xcode older than 27.options.timeout?:number— see Per-call timeout.
await device.getByText("Zoom here", { exact: true }).doubleTap();
// Custom interval for specific timing needsawait device.getByText("Zoom here").doubleTap({ intervalMs: 150 });elementHandle.longPress(options?): Promise<void>
Section titled “elementHandle.longPress(options?): Promise<void>”Long press this element.
options:number | { duration?: number; timeout?: number }— how long to hold, in milliseconds, either as a number or asduration(the agent’s default when omitted), and the per-call timeout. The press is held for that long, so a duration shorter than the app’s own long-press delay (often around 500 ms) can register as a tap. With the number form, the timeout goes in a second argument:longPress(2000, { timeout: 5000 }).
await device.getByText("Item 1", { exact: true }).longPress(2000);await device.getByText("Item 1", { exact: true }).longPress({ duration: 2000, timeout: 5000 });elementHandle.type(text: string, options?): Promise<void>
Section titled “elementHandle.type(text: string, options?): Promise<void>”Type text into this element.
options.delay?:number— delay in milliseconds between keystrokes. Overrides the globaltypingDelayconfig for this call. Defaults to0(no delay). On iOS a delay above0also checks what arrived (seetypingDelay). iOS empties a secure (password) field that already holds text when it is typed into after taking focus. On iOS,type()with a delay then fails rather than leave the field holding something else. Appending to a secure field that still has focus works. UseclearAndType()to replace a password.options.timeout?:number— see Per-call timeout.
await device.getByPlaceholder("Email").type("user@example.com");await device.getByPlaceholder("OTP").type("123456", { delay: 50 });Control characters.
\n,\t, and\bare dispatched asKEYCODE_ENTER/KEYCODE_TAB/KEYCODE_DELkey events on Android and the equivalent key events on iOS. Notably\bis destructive —type("foo\bbar")deletes theoand typesbar, ending withfobar. CR (\r) is dropped (Android keyboards send\nfor the Enter key). Other ASCII control codes below0x20are dropped with a one-shot warning log.
elementHandle.clearAndType(text: string, options?): Promise<void>
Section titled “elementHandle.clearAndType(text: string, options?): Promise<void>”Clear existing text and type new text.
options.delay?:number— delay in milliseconds between keystrokes. Same astype().options.timeout?:number— see Per-call timeout.
await device.locator({ id: "search_box" }).clearAndType("new query");elementHandle.clear(options?): Promise<void>
Section titled “elementHandle.clear(options?): Promise<void>”Clear the text content of this element. Takes the per-call timeout option.
await device.locator({ id: "search_box" }).clear();iOS very-long-field ceiling. On iOS,
clear()first attempts Cmd+A + Delete; if that misses (common on React Native wrapped controls), it falls back to a per-character backspace loop capped at 16 iterations × 256 keystrokes = 4096 backspaces. A field with more than ~4000 grapheme clusters of content will throwactionFailedrather than partially clearing. The cap exists so a misbehaving field can’t hang the agent. Android uses the nativeUiObject2.clear()API and isn’t subject to this limit.
elementHandle.scroll(direction: SwipeDirection, options?): Promise<void>
Section titled “elementHandle.scroll(direction: SwipeDirection, options?): Promise<void>”Scroll this element in the given direction: the way the view scrolls, so "down" reveals what is below. scrollIntoView() takes its direction the same way; device.swipe() takes the way the finger moves, the opposite. Options: distance (below) and the per-call timeout.
await device.getByTestId("product-list").scroll("down"); // a FlatList with testID="product-list"The device agents don’t apply distance yet: each call scrolls by a fixed step of the element’s size, whatever distance says. To reach a particular element, use scrollIntoView().
elementHandle.scrollIntoView(options?: { direction?: SwipeDirection; maxScrolls?: number; speed?: number }): Promise<void>
Section titled “elementHandle.scrollIntoView(options?: { direction?: SwipeDirection; maxScrolls?: number; speed?: number }): Promise<void>”Scroll the viewport until this element is visible on screen. Useful for reaching elements that are below the fold in a scrollable container.
Swipes, checking visibility between each attempt. Throws if the element is not visible after maxScrolls attempts, or sooner, as soon as a swipe changes nothing on screen: then the content has reached its end (or the swipe missed the list), and the error says reached the end of the scrollable content without finding …, with the direction to try instead. To tell, it reads the screen’s hierarchy after a swipe, unless the device saw the list under the finger move (below); on the iOS simulator two scroll steps in a row that saw the list hold still also end it. On a screen whose content keeps changing by itself (a running timer, an animation), every swipe looks like progress, so there it swipes up to maxScrolls times as before. Taps do not scroll for you (see Off-screen elements), so call this first for an element that may be off-screen.
direction is the way the view scrolls, as scroll() takes it: "down" reveals what is below and "up" what is above (each swipe moves the finger the opposite way). Without a direction, it scrolls the way the element lies, when the device reports where it is: "up" for an element above the screen, "left" for one to its left, and "down" otherwise. The position is re-read before every swipe, so a swipe that carries the element past the screen is followed by one back. iOS lists off-screen elements with their positions, so there a plain scrollIntoView() reaches a row above the screen, such as one the list was scrolled past. Android lists only the elements on screen, so there the position of an off-screen element is not known. Then, and on iOS for a row a lazy list has not created yet, it keeps the direction it last used, starting with "down", which reveals what is below. So on Android, pass { direction: "up" } for an element above the screen. A below-the-screen element and one to its right look the same without the screen’s size, so both get "down": pass { direction: "right" } for one to the right. Every swipe runs inside the scroll container that holds the element, so it cannot start outside a dialog or bottom sheet (which would dismiss it), on a header above the list, or on the keyboard. Where the device reports the element’s position (iOS), that is the element’s nearest scrollable ancestor on screen. Where it does not (Android, or a row a lazy list has not created yet), it is the largest scrollable on screen, taken from the topmost window on Android, where a dialog or bottom sheet has a window of its own over the screen’s. Only when nothing on screen scrolls does the swipe cross the middle of the screen, and the error then says so. Scrolling is still done with swipes, so in a bottom sheet that closes when dragged down, a swipe that reaches the top of the sheet’s content can go on to drag the sheet, as a user’s swipe would. Without the element’s position, a horizontal list inside a vertical one is not the largest scrollable, so for an element in a horizontal list on Android, or one a lazy horizontal list has not created yet on iOS, scroll() that list.
Each swipe is one call to the device, a scroll step: it drags most of the list (0.8 of it on Android; on iOS it starts a quarter in, clear of bars drawn over the list, and drags up to 0.73), waits for the list to stop changing, looks for the element, and says whether the list under the finger moved, so no further read is needed while the list keeps moving. On Android this is every vertical swipe of a locator without filter(), and()/or(), scoping or first()/nth()/last(): a quick drag that is cancelled at its end rather than lifted, so nothing flings and nothing under the finger is tapped (a React Native list that snaps to pages is left where the finger stopped). The first step also makes the first check described below, and picks the scroll container itself. On the iOS simulator it is every swipe of such a locator inside a scroll container (sideways only inside the scrollable nearest the element in the screen’s tree, as a slow sideways drag elsewhere can be taken for the swipe back to the previous screen): a drag through the simulator’s touch input that holds still before it lifts, so nothing flings; while the element is still more than a list’s height away, a step does not wait for the list to stop. Swipes that are not steps are as before: sideways on Android a swipe still flings, so a pager turns its page, and on a physical iPhone, for a modified locator on iOS, sideways on iOS when the scrollable holding the element is not known, or when nothing on screen scrolls, a swipe is a fling whose momentum scrollIntoView() watches, looking for the element while the list moves. Once the element is on screen, it waits until the element’s position stops changing (a list that snaps to a page can still move after a drag). If the list carried the element off screen before it stopped, it scrolls back for it. If an edge of the scroll container cuts the element, it moves it inside, so a tap() that follows lands on it: when the element reaches past the edge, when it was seen whole and the edge now hides part of it, or when only a sliver of it shows. A row that stopped partly in view at the edge it came in from, and was never seen whole, is left as it is (the tap still lands on its visible part). These swipes are shorter and slower, so the list does not overshoot again, and they count toward maxScrolls. When maxScrolls runs out before the element is found, the error says so: raise it to let the call scroll further. When it runs out with the element on screen but cut, the call returns.
If the element is already visible, this is a no-op — it returns without scrolling, so calling scrollIntoView() before every tap() is safe even when the target is on screen (an unnecessary swipe could otherwise shift it under a pinned app bar). When the element isn’t found on the first check, Tapsmith waits for the UI to settle and re-checks before the first swipe, so a briefly stale accessibility tree (e.g. right after navigation) doesn’t trigger a spurious scroll. A handle from all() is a live .nth(i) locator, so on a virtualised list the scroll stops on whatever row is at index i in the scrolled window — see the all() section.
The visibility check honours every modifier on the locator — filter(), and()/or(), scoping and first()/nth()/last() — so device.getByRole("listitem").filter({ hasText: "Zebra" }).scrollIntoView() swipes until that row is on screen rather than stopping at the first list item. Strict mode applies as for any single-element query: an ambiguous locator throws (before the first swipe, if it is ambiguous from the start) rather than scrolling toward an arbitrary match.
| Option | Default | Description |
|---|---|---|
direction |
from the element’s position, else "down" |
The way the view scrolls, as scroll() takes it: "down" reveals content below, "up" content above, "left" and "right" likewise. Case-insensitive; any other value throws. Given, it is used for every swipe that searches for the element, wherever the element is; a swipe back for an element the list carried past, or one that moves it inside its container, goes the way the element is. |
maxScrolls |
10 (5 when the platform is not known) |
Maximum swipe attempts before throwing (fewer if the content reaches its end first), counting any swipe back for an element the list carried past. A drag moves the list only about as far as the finger, where a fling carried it further, so this allows as many drags as it takes to go as far |
speed |
2000 |
Swipe speed in pixels/second, for the swipes that are not scroll steps |
// Scroll down until the "Settings" card is visible, then tap itawait device.getByDescription("Settings").scrollIntoView();await device.getByDescription("Settings").tap();
// Back up to a row above the screen: iOS knows where it is; Android needs the directionawait device.getByText("Top Section", { exact: true }).scrollIntoView({ direction: "up" });elementHandle.dragTo(target: ElementHandle, options?): Promise<void>
Section titled “elementHandle.dragTo(target: ElementHandle, options?): Promise<void>”Drag this element to a target element. The per-call timeout option is shared by the wait for both elements and the drag: each step gets what the earlier ones left of it. As for other actions, a step that starts with less than a second left still gets up to a second, so a call whose elements turn up late can end shortly after the timeout. Without the option, each element waits for its own locator’s timeout.
const source = device.getByText("Item 1", { exact: true });const target = device.getByText("Drop Zone", { exact: true });await source.dragTo(target);elementHandle.setChecked(checked: boolean, options?): Promise<void>
Section titled “elementHandle.setChecked(checked: boolean, options?): Promise<void>”Ensure a checkbox, switch, or radio button is in the desired state. Idempotent – only taps if the current state differs from the desired state, and verifies the state changed after tapping. The per-call timeout option bounds the whole call, the check that the state changed included. Like tap(), it does not scroll an off-screen element into view (see Off-screen elements).
await device.getByRole("switch", { name: "Dark Mode" }).setChecked(true);await device.getByRole("checkbox", { name: "Remember me" }).setChecked(false);elementHandle.selectOption(option: string | { index: number }, options?): Promise<void>
Section titled “elementHandle.selectOption(option: string | { index: number }, options?): Promise<void>”Select an option from a native spinner or dropdown. Abstracts the tap-spinner, wait-for-popup, tap-option pattern into a single action. Takes the per-call timeout option.
await device.getByRole("combobox").selectOption("Option 2");await device.getByRole("combobox").selectOption({ index: 1 });option is the option’s text or { index } (a non-negative integer). Anything else, including Playwright’s { value } and { label } forms, throws a TypeError.
elementHandle.focus(options?): Promise<void>
Section titled “elementHandle.focus(options?): Promise<void>”Programmatically focus this element. For text fields, this shows the keyboard. Takes the per-call timeout option.
await device.getByRole("textfield", { name: "Email" }).focus();elementHandle.blur(options?): Promise<void>
Section titled “elementHandle.blur(options?): Promise<void>”Remove focus from this element by tapping outside its bounds. Takes the per-call timeout option.
await device.getByRole("textfield", { name: "Email" }).blur();elementHandle.pinchIn(options?): Promise<void>
Section titled “elementHandle.pinchIn(options?): Promise<void>”Pinch in on (zoom out of) this element: two fingers on its diagonal close in on its centre. Options: scale, the fingers’ end distance as a share of their start distance, between 0 and 1 exclusive (default 0.5), and the per-call timeout. A scale outside that range throws before anything is sent to the device.
On iOS the fingers stay inside the part of the element that is on screen and not under the keyboard or a bar, and the call waits, like a tap, for any control drawn over the points where the fingers touch down to go away. It fails with an error, rather than reporting a pinch that did not happen, when that control stays, when the element is off screen, when its visible part is too small for two fingers, or when the device cannot synthesize the gesture. On Android the fingers move across the element’s visible bounds, without a check for what is drawn over them.
On a very small element or with an extreme scale, the fingers stop short of the asked distance rather than touching (at least 40 pt apart on iOS, a tenth of the full span on Android). Android also always moves the fingers at least a tenth of the full span, so a scale within 0.1 of 1 pinches a little further than asked there.
await device.getByText("Map", { exact: true }).pinchIn();await device.getByText("Map", { exact: true }).pinchIn({ scale: 0.3 });elementHandle.pinchOut(options?): Promise<void>
Section titled “elementHandle.pinchOut(options?): Promise<void>”Pinch out on (zoom into) this element: two fingers spread apart from its centre along its diagonal. Options: scale, the fingers’ end distance as a multiple of their start distance, above 1 (default 2), and the per-call timeout. It behaves on each platform as pinchIn() does.
await device.getByText("Map", { exact: true }).pinchOut();await device.getByText("Map", { exact: true }).pinchOut({ scale: 3.0 });elementHandle.highlight(options?: { durationMs?: number }): Promise<void>
Section titled “elementHandle.highlight(options?: { durationMs?: number }): Promise<void>”Highlight this element for debugging. Validates that the element exists and is accessible.
await device.getByRole("button", { name: "Submit" }).highlight();elementHandle.screenshot(options?): Promise<Buffer>
Section titled “elementHandle.screenshot(options?): Promise<Buffer>”Capture a screenshot cropped to this element’s bounding box. Returns a Buffer containing PNG image data. Takes the per-call timeout option.
const png = await device.getByRole("image", { name: "Profile" }).screenshot();Info Accessors
Section titled “Info Accessors”elementHandle.getText(options?): Promise<string>
Section titled “elementHandle.getText(options?): Promise<string>”Get the visible text content of this element. Waits for the element like find(), for up to the per-call timeout when one is given. To read the text of
every match in one hierarchy read, use allTextContents().
const label = await device.locator({ id: "status_label" }).getText();elementHandle.isVisible(options?): Promise<boolean>
Section titled “elementHandle.isVisible(options?): Promise<boolean>”Check whether this element is visible on screen right now.
Like Playwright’s locator.isVisible(), this does not wait for the element to appear: it reads the
current state and returns false when no element matches, so it is safe to branch on presence. To
wait for an element to become visible, use expect(locator).toBeVisible() or waitFor().
const visible = await device.getByText("Error", { exact: true }).isVisible();
// Presence branch — answers at once, no timeout is spent on a missing elementif (!(await device.getByRole("button", { name: "Enable notifications" }).isVisible())) { return; // no button to tap on this screen — nothing to do here}Strict mode still applies: a locator that matches more than one element throws a
StrictModeViolationError. On a settled screen a present element costs one hierarchy read on the
device (it never polls for the element). An absent answer costs two: the accessibility tree can briefly
lag a just-rendered screen, so the first empty read is confirmed by waiting for the UI to settle (at
most 1.5 s) and reading once more, as scrollIntoView() does before its first swipe. If that re-read
only yields stale snapshots (a spinner keeps the tree churning), it is retried for a couple of seconds
and then the first empty read stands as the answer — an absent element never costs the whole timeout. Infrastructure
problems are never reported as a visibility answer: a momentary agent fault is retried for a short
window after it first appears (about two seconds, capped by the handle’s timeout) and then thrown. An
agent command timeout is different: the agent is alive but slow (a CPU-starved CI emulator’s hierarchy
dump), so it is retried until the handle’s timeout, exactly as an action would be, and only then thrown.
A stale mid-re-render snapshot
just means the screen is busy, so — like find() and waitFor() — it is re-read until the handle’s
timeout; if the hierarchy never settles (a screen that never stops animating) the call keeps re-reading
for the handle’s timeout and then throws a descriptive error, pointing at expect(locator).toBeVisible() /
.not.toBeVisible() and waitFor(), rather than guessing an answer.
Behaviour change. Before this release
isVisible()waited for the element likefind()and threw when it never appeared, soexpect(await x.isVisible()).toBe(true)straight after a navigation used to pass by waiting. It now reads the screen as it is at that instant and may returnfalsewhile the new screen is still appearing. To wait for visibility, useawait expect(x).toBeVisible(); useisVisible()/isHidden()only to branch on the current state.
isVisible() and isHidden() take a timeout option, as Playwright’s do, but it does not make them wait for the element (Playwright ignores the option altogether). It replaces the handle’s timeout for the retries described above: re-reads of a screen caught mid-re-render, retries of a slow or faulty agent, and the settle wait before an absent answer, which is capped at the timeout — so a very short timeout can answer false before a lagging accessibility tree catches up.
Only isVisible(), isHidden() and exists() are non-waiting. isEnabled(), isChecked() and isEditable()
follow Playwright too: they wait for the element to be present and throw if it never appears, so a
negative answer always describes a real element.
elementHandle.isHidden(options?): Promise<boolean>
Section titled “elementHandle.isHidden(options?): Promise<boolean>”The opposite of isVisible(): true when the element is not visible or does not exist. Does not
wait for the element to appear or disappear. To wait for an element to go away, use
expect(locator).not.toBeVisible() or waitFor({ state: "hidden" }).
Like Playwright’s isHidden(), this is a strict single-element query: a locator that matches more
than one element throws a StrictModeViolationError, whereas not.toBeVisible() and
waitFor({ state: "hidden" }) evaluate absence over all matches. If the locator may match several
elements (say, two “Loading…” spinners), use those, or narrow with .first()/.filter().
// Presence branch: dismiss a banner only if one is showingif (!(await device.getByRole("button", { name: "Dismiss" }).isHidden())) { await device.getByRole("button", { name: "Dismiss" }).tap();}Do not use isHidden() to wait for a spinner or animation to finish: an animating screen is exactly
what produces stale snapshots, so the probe would have to keep re-reading and, if the animation never
stops, throws. await expect(device.getByText("Loading…")).not.toBeVisible() is the tool for that.
elementHandle.isEnabled(options?): Promise<boolean>
Section titled “elementHandle.isEnabled(options?): Promise<boolean>”Check whether this element is enabled (interactive). Waits for the element to be present (up to the handle’s timeout, or the per-call timeout when one is given) and throws if it never appears.
const enabled = await device.getByRole("button", { name: "Submit" }).isEnabled();elementHandle.isChecked(options?): Promise<boolean>
Section titled “elementHandle.isChecked(options?): Promise<boolean>”Check whether this checkbox, switch, or radio button is in the checked state. Waits for the element to be present (up to the handle’s timeout, or the per-call timeout when one is given) and throws if it never appears.
const checked = await device.getByRole("switch", { name: "Notifications" }).isChecked();elementHandle.isEditable(options?): Promise<boolean>
Section titled “elementHandle.isEditable(options?): Promise<boolean>”Check whether this element is an editable input field (text field role and enabled). Waits for the element to be present (up to the handle’s timeout, or the per-call timeout when one is given) and throws if it never appears.
const editable = await device.getByRole("textfield", { name: "Email" }).isEditable();elementHandle.inputValue(options?): Promise<string>
Section titled “elementHandle.inputValue(options?): Promise<string>”Get the current value of an input field. On Android, this returns the element’s text property. Waits
for the element to be present (up to the handle’s timeout or the per-call timeout, on every handle shape including
.first()/.nth()/.filter()) and throws if it never appears.
const value = await device.getByRole("textfield", { name: "Email" }).inputValue();elementHandle.boundingBox(options?): Promise<BoundingBox | null>
Section titled “elementHandle.boundingBox(options?): Promise<BoundingBox | null>”Get the element’s position and dimensions. Returns null if the element has no bounds. Waits for the
element to be present (up to the handle’s timeout or the per-call timeout, on every handle shape including
.first()/.nth()/.filter()) and throws if it never appears.
const box = await device.getByText("Header", { exact: true }).boundingBox();// Returns: { x: number, y: number, width: number, height: number }
