Skip to content

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.

ElementHandle exposes the same getBy* methods and locator() as Device. Calling any of them on an existing handle scopes the search to its descendants — exactly like Playwright’s locator.locator(...).

MethodDescription
getByText(text, options?)Substring (default) or exact 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. When the parent carries such a modifier it is resolved to its concrete element(s) and the child is scoped to them by geometric containment, so the parent must report bounds. A positional parent (.first()/.nth()) scopes to its single selected element; a filtering parent scopes to every match it resolves to (a child contained in any of them is in scope).

const list = device.getByRole("list", { name: "Shopping cart" });
const item = list.getByText("Item 3", { exact: true });
await item.tap();
// Tap a delete button inside a specific row
await device.getByTestId("row-5").getByRole("button", { name: "Delete" }).tap();
// Scope into a positional match — the submit button inside the first dialog
await device.getByTestId("dialog").first().getByRole("button", { name: "Submit" }).tap();

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();

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.

await device.getByRole("listitem").nth(2).tap();
await device.getByRole("listitem").nth(-1).tap(); // last item

elementHandle.filter(criteria: FilterOptions): ElementHandle

Section titled “elementHandle.filter(criteria: FilterOptions): ElementHandle”

Narrow matches by additional criteria without changing the selector. Returns a new lazy handle.

const premiumItems = device.getByRole("listitem").filter({ hasText: "Premium" });
const count = await premiumItems.count();

FilterOptions:

OptionTypeDescription
hasTextstring | RegExpKeep elements whose text contains this string or matches this RegExp
hasNotTextstring | RegExpExclude elements whose text contains this string or matches this RegExp
hasElementHandleKeep elements that have a descendant matching this locator
hasNotElementHandleExclude elements that have a descendant matching this locator

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 selector (intersection). AND binds tighter than OR.

const submitButton = device.getByRole("button").and(device.getByText("Submit", { exact: true }));
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 selector (union).

const acceptButton = device.getByText("OK", { exact: true }).or(device.getByText("Accept", { exact: true }));
await acceptButton.tap();

elementHandle.find(): Promise<ElementInfo>

Section titled “elementHandle.find(): Promise<ElementInfo>”

Resolve the handle to an ElementInfo object. Throws if the element is not found within the timeout.

The ElementInfo object contains:

PropertyTypeDescription
elementIdstringInternal element identifier
classNamestringAndroid class name
textstringVisible text content
contentDescriptionstringAccessibility content description
resourceIdstringAndroid resource ID
enabledbooleanWhether the element is enabled
visiblebooleanWhether the element is visible
clickablebooleanWhether the element is clickable
focusablebooleanWhether the element is focusable
scrollablebooleanWhether the element is scrollable
hintstringInput hint text
checkedbooleanWhether the element is checked
selectedbooleanWhether the element is selected
focusedbooleanWhether the element has input focus
rolestringAccessibility role (e.g. “button”, “textfield”)
viewportRationumberFraction of element visible in viewport (0.0-1.0)
boundsBoundsElement bounding rectangle

Returns true if the element exists in the current UI hierarchy.

const exists = await device.getByText("Optional banner", { exact: true }).exists();

Return the number of elements matching the selector.

const itemCount = await device.getByRole("listitem").count();

elementHandle.all(): Promise<ElementHandle[]>

Section titled “elementHandle.all(): Promise<ElementHandle[]>”

Return an array of ElementHandle instances, one for each matching element. Useful for iterating over a list of elements.

const items = await device.getByRole("listitem").all();
for (const item of items) {
const info = await item.find();
console.log(info.text);
}

The returned handles carry the snapshot they were resolved from, so reading and acting on the batch does not re-query the device for every element: find(), getText(), isEnabled(), isChecked(), isEditable(), inputValue(), boundingBox(), isVisible(), isHidden() and actions all address the element captured by all(), so a check and the action it guards describe the same element (after an action other than scrollIntoView() that moves the list, items[i].boundingBox() still reports the captured coordinates). Scoped children (items[i].getByRole("button")) resolve their parent live by index, like .nth(i): once the list has changed they address whatever row is now at index i, so call all() again after the list changes before acting on a row’s children. Three things refresh the snapshot from a fresh read by the same index: an action whose captured element went stale mid-action, a wait the capture cannot satisfy (a disabled control, a setChecked() state change being confirmed, an index the capture no longer has), and items[i].scrollIntoView(), which re-captures before every check because it exists to watch the screen change; the handle keeps answering from the refreshed capture. Note what that means: after items[i].scrollIntoView() the handle describes whatever row is at index i in the scrolled list — on a virtualised list that can be a different row from the one captured, exactly as .nth(i) would resolve — so prefer a locator that names the row (getByText, filter({ hasText })) when scrolling to it. expect(items[i]) assertions and waitFor() also re-query the device by index, so they reflect what is on screen now. Because a handle from all() already names one element, first(), last(), nth(), filter(), and() and or() throw on it, and so does passing it as the other operand or as has/hasNot — narrow the locator before calling all() instead (list.filter({ hasText: "Sold out" }).all()). Note that filters always apply before a positional index, so list.nth(1).filter(…) means “the second matching row”, not “row 1 if it matches”. If the list changes after all() (an item is removed, say), call all() again, or use .nth(i) for a handle that always resolves live. Making all() return live locators, as Playwright does, is tracked as PILOT-346.

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 disappear
await 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 entirely
await 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:

OptionTypeDefaultDescription
state'visible' | 'hidden' | 'attached' | 'detached''visible'Target state to wait for
timeoutnumberDevice timeout (30s)Maximum time to wait in milliseconds

States:

StateCondition
visibleElement exists in the hierarchy AND is visible
hiddenElement doesn’t exist OR exists with visible === false
attachedElement exists in the hierarchy (regardless of visibility)
detachedElement does not exist in the hierarchy

Tap this element.

await device.getByRole("button", { name: "Submit" }).tap();

elementHandle.doubleTap(options?: { intervalMs?: number }): Promise<void>

Section titled “elementHandle.doubleTap(options?: { intervalMs?: number }): Promise<void>”

Double-tap this element.

  • options.intervalMs?: number — interval in milliseconds between the two taps. Overrides the global doubleTapInterval config for this call. Defaults to 100. On iOS, the interval is used for the coordinate-based EventSynthesizer path; the XCUIElement path handles timing internally.
await device.getByText("Zoom here", { exact: true }).doubleTap();
// Custom interval for specific timing needs
await device.getByText("Zoom here").doubleTap({ intervalMs: 150 });

elementHandle.longPress(durationMs?: number): Promise<void>

Section titled “elementHandle.longPress(durationMs?: number): Promise<void>”

Long press this element.

await device.getByText("Item 1", { exact: true }).longPress(2000);

elementHandle.type(text: string, options?: { delay?: number }): Promise<void>

Section titled “elementHandle.type(text: string, options?: { delay?: number }): Promise<void>”

Type text into this element.

  • options.delay?: number — delay in milliseconds between keystrokes. Overrides the global typingDelay config for this call. Defaults to 0 (no delay).
await device.getByPlaceholder("Email").type("[email protected]");
await device.getByPlaceholder("OTP").type("123456", { delay: 50 });

Control characters. \n, \t, and \b are dispatched as KEYCODE_ENTER / KEYCODE_TAB / KEYCODE_DEL key events on Android and the equivalent key events on iOS. Notably \b is destructivetype("foo\bbar") deletes the o and types bar, ending with fobar. CR (\r) is dropped (Android keyboards send \n for the Enter key). Other ASCII control codes below 0x20 are dropped with a one-shot warning log.

elementHandle.clearAndType(text: string, options?: { delay?: number }): Promise<void>

Section titled “elementHandle.clearAndType(text: string, options?: { delay?: number }): Promise<void>”

Clear existing text and type new text.

  • options.delay?: number — delay in milliseconds between keystrokes. Same as type().
await device.locator({ id: "search_box" }).clearAndType("new query");

Clear the text content of this element.

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 throw actionFailed rather than partially clearing. The cap exists so a misbehaving field can’t hang the agent. Android uses the native UiObject2.clear() API and isn’t subject to this limit.

elementHandle.scroll(direction: string, options?: { distance?: number }): Promise<void>

Section titled “elementHandle.scroll(direction: string, options?: { distance?: number }): Promise<void>”

Scroll this element in the given direction.

await device.getByRole("list").scroll("down", { distance: 300 });

elementHandle.scrollIntoView(options?: { direction?: string; maxScrolls?: number; speed?: number }): Promise<void>

Section titled “elementHandle.scrollIntoView(options?: { direction?: string; 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 in the given direction, checking visibility between each attempt. Throws if the element is not visible after maxScrolls attempts.

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. For a handle from all() the check first re-captures the row by index — see the note in the all() section for what that means on a virtualised list.

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.

OptionDefaultDescription
direction"up"Swipe direction. "up" scrolls down (reveals content below), "down" scrolls up (reveals content above).
maxScrolls5Maximum swipe attempts before throwing
speed2000Swipe speed in pixels/second
// Scroll down until the "Settings" card is visible, then tap it
await device.getByDescription("Settings").scrollIntoView();
await device.getByDescription("Settings").tap();
// Scroll up (reverse direction)
await device.getByText("Top Section", { exact: true }).scrollIntoView({ direction: "down" });

elementHandle.dragTo(target: ElementHandle): Promise<void>

Section titled “elementHandle.dragTo(target: ElementHandle): Promise<void>”

Drag this element to a target element.

const source = device.getByText("Item 1", { exact: true });
const target = device.getByText("Drop Zone", { exact: true });
await source.dragTo(target);

elementHandle.setChecked(checked: boolean): Promise<void>

Section titled “elementHandle.setChecked(checked: boolean): 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.

await device.getByRole("switch", { name: "Dark Mode" }).setChecked(true);
await device.getByRole("checkbox", { name: "Remember me" }).setChecked(false);

elementHandle.selectOption(option: string | { index: number }): Promise<void>

Section titled “elementHandle.selectOption(option: string | { index: number }): 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.

await device.getByRole("combobox").selectOption("Option 2");
await device.getByRole("combobox").selectOption({ index: 1 });

Programmatically focus this element. For text fields, this shows the keyboard.

await device.getByRole("textfield", { name: "Email" }).focus();

Remove focus from this element by tapping outside its bounds.

await device.getByRole("textfield", { name: "Email" }).blur();

elementHandle.pinchIn(options?: { scale?: number }): Promise<void>

Section titled “elementHandle.pinchIn(options?: { scale?: number }): Promise<void>”

Perform a pinch-in (zoom out) gesture on this element.

await device.getByText("Map", { exact: true }).pinchIn();
await device.getByText("Map", { exact: true }).pinchIn({ scale: 0.3 });

elementHandle.pinchOut(options?: { scale?: number }): Promise<void>

Section titled “elementHandle.pinchOut(options?: { scale?: number }): Promise<void>”

Perform a pinch-out (zoom in) gesture on this element.

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(): Promise<Buffer>

Section titled “elementHandle.screenshot(): Promise<Buffer>”

Capture a screenshot cropped to this element’s bounding box. Returns a Buffer containing PNG image data.

const png = await device.getByRole("image", { name: "Profile" }).screenshot();

Get the visible text content of this element.

const label = await device.locator({ id: "status_label" }).getText();

elementHandle.isVisible(): Promise<boolean>

Section titled “elementHandle.isVisible(): 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 element
if (!(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 selector 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 or agent timeout is retried for a short window after it first appears (about two seconds, capped by the handle’s timeout) and then thrown. The window bounds when the next re-read may be scheduled, not the wall clock: a read that is itself slow to fail (an agent command timeout takes ~5 s to come back) can add one more such read before the fault is 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. A handle obtained from all() answers from the snapshot it was created from, like every other reader on that handle (see all()).

Behaviour change. Before this release isVisible() waited for the element like find() and threw when it never appeared, so expect(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 return false while the new screen is still appearing. To wait for visibility, use await expect(x).toBeVisible(); use isVisible()/isHidden() only to branch on the current state.

Only isVisible() and isHidden() 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(): Promise<boolean>

Section titled “elementHandle.isHidden(): 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 selector that matches more than one element throws a StrictModeViolationError, whereas not.toBeVisible() and waitFor({ state: "hidden" }) evaluate absence over all matches. If the selector may match several elements (say, two “Loading…” spinners), use those, or narrow with .first()/.filter().

// Presence branch: dismiss a banner only if one is showing
if (!(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(): Promise<boolean>

Section titled “elementHandle.isEnabled(): Promise<boolean>”

Check whether this element is enabled (interactive). Waits for the element to be present (up to the handle’s timeout) and throws if it never appears.

const enabled = await device.getByRole("button", { name: "Submit" }).isEnabled();

elementHandle.isChecked(): Promise<boolean>

Section titled “elementHandle.isChecked(): 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) and throws if it never appears.

const checked = await device.getByRole("switch", { name: "Notifications" }).isChecked();

elementHandle.isEditable(): Promise<boolean>

Section titled “elementHandle.isEditable(): 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) and throws if it never appears.

const editable = await device.getByRole("textfield", { name: "Email" }).isEditable();

elementHandle.inputValue(): Promise<string>

Section titled “elementHandle.inputValue(): 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, 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(): Promise<BoundingBox | null>

Section titled “elementHandle.boundingBox(): 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, 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 }