KMPwright

Locators

Find elements with page.locator(selector) and the getBy* finders, compose locators with filter/nth/and/or, and read element state.

page.goto("https://example.com/login")

page.getByLabel("Email").fill("ada@example.com")
page.getByLabel("Password").fill("correct horse")
page.getByRole("button", name = "Sign in").click()

val paid = page.locator("table tr").filter(hasText = "Paid")
println(paid.count())
println(page.getByTestId("balance").textContent())

A Locator is a lazy description of how to find elements. Creating one does nothing in the page; the selector is resolved again every time you call an action or query on it, so a locator stays valid across re-renders. Playwright: same model.

Locators come from any ElementFinder: Page, Locator (scoped to its matches) and FrameLocator (scoped to an iframe). Snapshot refs from snapshots (@e3) are plain selectors, so agent output and handwritten code use the same API.

Finders

Every ElementFinder (Page, Locator, FrameLocator) has these methods. Each returns a Locator; nothing is resolved until you act on it.

MethodMatchesSelector it builds
locator(selector)Any selector, including refsas given
getByRole(role, name, exact, …)ARIA role and accessible namerole=…[name=…]
getByRole(role, name: Regex, …)Role, accessible name matches the regexrole=…[name=/…/]
getByText(text, exact = false) / getByText(Regex)Element texttext=…
getByLabel(text, exact = false) / getByLabel(Regex)Form controls by <label>, aria-label or aria-labelledbylabel=…
getByPlaceholder(text, exact = false) / getByPlaceholder(Regex)placeholder attributeplaceholder=…
getByAltText(text, exact = false) / getByAltText(Regex)alt text of imagesalt=…
getByTitle(text, exact = false) / getByTitle(Regex)title attributetitle=…
getByTestId(String) / getByTestId(Regex)data-testid attributetestid=…
frameLocator(selector)Elements inside an iframesee Dialogs and frames

Text matching is the same for all String finders:

  • Default: case-insensitive substring, with whitespace collapsed ("sign in" matches Sign In now).
  • exact = true: the whole whitespace-normalised string, case-sensitive.
  • Regex: tested against the whitespace-normalised text. RegexOption.IGNORE_CASE, MULTILINE and DOT_MATCHES_ALL become the JavaScript flags i, m and s.

getByTestId(String) is the exception. It is an exact, case-sensitive match on data-testid. You can't change the test id attribute. Playwright: selectors.setTestIdAttribute has no equivalent.

getByRole

page.getByRole("button", name = "Save").click()
page.getByRole("checkbox", name = "Subscribe", checked = false).check()
page.getByRole("heading", level = 2).first().textContent()
page.getByRole("link", name = Regex("^Docs")).click()
page.getByRole("tab", selected = true).innerText()

Prop

Type

How roles and names are computed depends on the backend:

BackendRole and accessible name source
JVM (Chromium CDP)Chrome's accessibility tree. Before a role= query runs, KMPwright attaches the browser's computed role and name to each node and uses them. Elements without that data fall back to the built-in rules below.
Android WebView, iOS WKWebViewBuilt-in rules in the page runtime: explicit role attribute first, then implicit roles (button, a[href] → link, input types → textbox/checkbox/radio/slider/spinbutton/searchbox/combobox, select → combobox/listbox, h1–h6 → heading, img → img, nav, main, lists, tables, dialog, …). The name comes from aria-labelledby, then aria-label, then associated <label>s, button values, alt, placeholder, <legend>/<caption>, text content (for roles named from content), then title.

Explicit roles and common HTML elements give the same results on every backend. Unusual markup (for example aria-owns or <section> without a label) may differ.

getByRole("input") is a KMPwright extension: it matches any textbox, searchbox, combobox, spinbutton or listbox, plus every input, textarea and select element.

Selector syntax

locator(selector) accepts these engines. A selector without a recognised engine= prefix is CSS.

SelectorMatches
button.primary, #id, css=div > aCSS. Queried on the document and on every open shadow root.
xpath=//form//inputXPath, evaluated from the current scope. Only element nodes are returned. The xpath= prefix is required: a bare //div is parsed as CSS and fails as invalid.
text=Sign inElements whose text contains "Sign in" (case-insensitive, whitespace-normalised).
text="Sign in"Elements whose whole text is exactly "Sign in" (case-sensitive, whitespace-normalised).
text=/sign\s+in/iElements whose text matches the regular expression. Flags d i m s u y are allowed.
role=button[name="Save"]ARIA role with attribute filters (below).
label=EmailControls labelled by matching <label> text, aria-label or aria-labelledby.
placeholder=SearchElements whose placeholder matches.
alt=Logoimg, area and input elements whose alt matches, plus [role=img] elements whose aria-label matches.
title=CloseElements whose title attribute matches.
testid=submitdata-testid equal to submit. testid=/^row-/ matches a regex.
@e3, e3, ref=e3, ref=@e3The element with that ref in the latest snapshot.
form >> text=SaveChaining: each part runs inside the matches of the previous part.

label=, placeholder=, alt= and title= use the same text rules as text=: plain value for a substring, "quoted" for exact, /regex/flags for a regex.

The command-compatible aliases text:Sign in and exact_text=Sign in are also supported. Prefer the getBy* finders in Kotlin workflows.

Text matching details

text= matches against the element's rendered text (innerText; for <input type=button|submit|reset>, its value). It returns the deepest elements that match: if a <button> and its parent <div> both contain "Save", only the button is returned. Text inside script, style, noscript, head, template and title is ignored.

Role attributes

role= takes attribute filters in square brackets, the same ones getByRole builds:

AttributeExampleNotes
name[name="Save"], [name=/^Save/i]A quoted value is a case-insensitive substring. Add s for an exact, case-sensitive match ([name="Save" s]) or i for an exact, case-insensitive match ([name="save" i]).
checked[checked], [checked=false], [checked=mixed]A bare attribute means true.
disabled[disabled], [disabled=false]
expanded[expanded=true]
selected[selected]
pressed[pressed=true], [pressed=mixed]Compared with the aria-pressed attribute.
level[level=2]
include-hidden[include-hidden]Include elements hidden from assistive technology.

Shadow DOM

Open shadow roots are pierced by every engine except XPath:

  • CSS runs querySelectorAll on the document and separately on each open shadow root. A single CSS selector can't cross a shadow boundary: my-card button does not match a button inside my-card's shadow root. Chain instead: my-card >> button.
  • text=, role=, label=, placeholder=, alt=, title= and testid= walk open shadow trees.
  • Closed shadow roots are never pierced.

Snapshot refs

page.locator("@e3") targets the element that page.snapshot() labelled e3. e3, ref=e3 and ref=@e3 are equivalent when they make up the whole selector or a whole >> part of it. A ref is not re-resolved by role or name. It points at that exact DOM node, and matches nothing once the node is detached or a new document is loaded. Take a new snapshot after the page changes.

Inside locator.locator(...) and filters, write the ref=e3 form. Refs only match within the scope of the parent locator.

Invalid selectors

Syntax errors fail right away with a KmpwrightException whose message starts with Invalid selector. KMPwright does not retry them until the timeout. This covers malformed CSS, a bad XPath expression, an invalid regular expression and unparseable role= attributes.

Composing locators

val row = page.locator("tr")
    .filter(hasText = "Ada Lovelace")
    .filter(has = page.getByRole("button", name = "Edit"))
row.getByRole("button", name = "Edit").click()

val items = page.getByRole("listitem")
items.first().click()
items.nth(-2).click()            // second to last
items.filter(hasNotText = "archived").count()
items.filter(hasText = Regex("^Q[1-4]")).count()

val submit = page.getByRole("button").and(page.locator("[type=submit]"))
val dismiss = page.getByText("Close").or(page.getByLabel("Dismiss"))

for (item in items.all()) println(item.innerText())
MethodBehavior
locator(selector) and getBy*Search inside every current match (descendants only, never the element itself).
first() / last() / nth(index)Pick one match. A negative index counts from the end. An out-of-range index matches nothing.
filter(hasText, hasNotText, has, hasNot)Keep matches whose text contains hasText, does not contain hasNotText, has a descendant matching has, and has none matching hasNot. Text matching follows the same rules as getByText (case-insensitive substring).
filter(hasText: Regex?, hasNotText: Regex?, has, hasNot)The same with regular expressions.
and(locator)Matches of this locator that also match locator.
or(locator)Matches of either locator, deduplicated, in document order.
all()One locator per element matching right now (nth(0)…nth(count - 1)). The elements are not awaited.

has, hasNot, and and or take a Locator from the same page. A locator from another page throws KmpwrightException. The has/hasNot locator is evaluated relative to each candidate, so page.getByRole("button") inside filter(has = …) means "a button inside this element".

Steps keep their order. locator("li").nth(1).locator("a") searches inside the second li, and locator("li a").nth(1) takes the second link overall.

Strictness

KMPwright enforces Playwright-style strict mode by default (KmpwrightConfig.strictSelectors = true). An operation that needs one element fails immediately with Strict mode violation: '<selector>' resolved to N elements (…). Use first(), last(), nth() or a more specific selector. when the locator matches more than one element. Strict operations:

  • every action (click, fill, press, check, …),
  • single-element queries (textContent, innerText, innerHTML, getAttribute, inputValue, boundingBox, isVisible, isEnabled, isChecked, isEditable, isFocused, evaluate, …).

These operations work on all matches and are never strict: count(), all(), allTextContents(), allInnerTexts(). waitFor(state) checks the first match.

To act on the first match instead of failing, call first(). To turn strictness off globally, set KmpwrightConfig(strictSelectors = false). Every single-element operation then uses the first match.

Reading element state

Queries run once and return. They don't auto-wait, except where noted. To wait for a state, use assertions (expect(locator).toBeVisible()) or waitFor.

MethodReturnsWhen nothing matches
count()Number of matches0
textContent()textContentnull
innerText()Rendered innerText""
innerHTML()innerHTML""
getAttribute(name)Attribute value, null if absentnull
inputValue()value of an input, textarea or selectnull
allTextContents()textContent of every matchempty list
allInnerTexts()innerText of every matchempty list
boundingBox()Rectangle(x, y, width, height) in CSS pixels, viewport-relativenull
isVisible()Non-empty box and visibility not hidden/collapsefalse
isHidden()!isVisible()true
isEnabled() / isDisabled()Not :disabled and no aria-disabled="true" on it or an ancestorthrows
isChecked()Checkbox/radio state or aria-checked (mixed counts as checked); throws for other elementsthrows
isEditable()Enabled, a fillable element, not readonly/aria-readonlythrows
isFocused()Element is the active element of its document or shadow rootfalse
waitFor(state, timeoutMs)Waits until the first match is Attached, Detached, Visible (default) or Hiddenwaits
evaluate("el => …", timeoutMs)JsResult(value, type) of a function called with the element. The function runs synchronously and a returned Promise is not awaited. Waits for the element to be attached.waits, then throws
screenshot(omitBackground, quality, format, scale, timeoutMs)Image of the element's box (scrolls it into view first); see Screenshotswaits, then throws

Opacity is ignored by isVisible(): an opacity: 0 element with a size counts as visible. Playwright: same.

waitFor uses TimeoutConfig.waitForSelector when timeoutMs is null. The other queries take no timeout.

The DOM-reading queries (textContent, innerText, innerHTML, getAttribute, inputValue, count, all*, boundingBox) throw when KmpwrightConfig.allowDomExtraction = false. evaluate throws when allowEval = false.

Page shortcuts

The same queries exist on Page, taking a selector:

page.textContent("#total")              // waits for the element to be attached
page.getAttribute("a.next", "href")
page.inputValue("#email")
page.isVisible("#banner")               // no waiting
page.count("li")
page.waitForSelector("#results", SelectorState.Visible)

textContent, innerText, innerHTML, getAttribute and inputValue on Page first wait for the element to be attached. They take an optional timeoutMs, which defaults to TimeoutConfig.waitForSelector. isVisible, isHidden, isEnabled, isDisabled, isChecked, isEditable and count don't wait.

On this page