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

Method Description
getByText(text, options?) 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. 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.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 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. 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 item

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
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

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.

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:

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)
bounds Bounds Element bounding rectangle

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 element
const 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) before false came back. It now answers at once. Code that relied on exists() to wait for an element to appear should use await expect(x).toExist() or x.waitFor({ state: "attached" }) — or, for a locator that may match several elements, await expect(x.first()).toExist() or await expect(x).toHaveCount(n), since the waiting forms are strict and exists() is not.

Two further changes affect handles with .filter(), .and(), .or(), or a getBy* scoped to a modified parent. Those used to report false for any error, so x.filter({ hasText: "X" }).exists() answered false when the filter kept several candidates; it now answers true, 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.

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 read
const names = await device.getByRole("listitem").allTextContents();
// Iterate all() when each row needs its own action
for (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 up
if (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), while expect(), waitFor(), isVisible() and count() re-queried live — so a check and the action it guarded could describe different elements, and first(), nth(), filter(), and(), or() and all() 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 as for (const row of rows) await row.getText() now costs one hierarchy read per row (use allTextContents() for a batch read), and list.nth(1).filter(…) — on any positional locator, not only one from all() — now means “row 1 if it matches” rather than “the second matching row”; write list.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"]);

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:

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

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

Tap this element.

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

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 is in the way, 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 (PILOT-364).

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?: { 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("user@example.com");
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 destructive — type("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.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?: 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. 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 "up" Swipe direction. "up" scrolls down (reveals content below), "down" scrolls up (reveals content above).
maxScrolls 5 Maximum swipe attempts before throwing
speed 2000 Swipe 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. Waits for the element like find(). To read the text of every match in one hierarchy read, use allTextContents().

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 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 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(), 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(): 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 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 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 }