Selectors & Locators
Every interaction — a click, a fill, an assertion — starts by pointing at an element. This page is the complete guide to naming the element you want. What you do with it afterwards lives in the Element API and Assertions.
Three ways to name an element
CraftDriver accepts three kinds of "where is it?", and they all resolve to the same kind of element handle:
import { Browser, By } from 'craftdriver';
// 1. A CSS string — quickest for structural matching
await browser.click('#submit');
// 2. A By.* locator — a strategy object for one specific way of matching
await browser.find(By.role('button', { name: 'Save' })).click();
// 3. A getBy* shortcut — the common semantic strategies, straight on the browser
await browser.getByRole('button', { name: 'Save' }).click();A few rules tie these together:
- A bare string is always a CSS selector. There is no
text=orrole=string shorthand — to match by text or role, use aBy.*locator or agetBy*method. getByRole(...)is exactlyfind(By.role(...)). ThegetBy*methods are ergonomic shortcuts for the most common semanticBy.*strategies. Use whichever reads better; they resolve identically.find()vslocator().find()returns a one-shot handle.locator()returns a lazy, re-resolving handle you can filter, index (.nth()), and scope — see Locators (lazy & chainable). Both accept a string or aBy.
Which one should I reach for? Prefer the locator that is at once the most stable and the most meaningful. A role, label, or visible-text locator reads like intent and doubles as an accessibility signal — reach for it while the name is stable. When the name is dynamic (
"Count is 0") or the UI is translated, don't pin to it: anchor on a stable id or attribute (adata-testidif the app already has one). Drop to CSS for structural matching, and to XPath only when nothing else fits. See Choosing a locator.
Quick reference
| To find an element by… | Use | It matches, e.g. |
|---|---|---|
| Role + its label | getByRole('button', { name: 'Save' }) | <button>Save</button> |
| Visible text | getByText('Save') | <span>Save</span> |
| A form field's label | getByLabel('Email') | <label>Email</label><input> |
| Placeholder text | getByPlaceholder('Search') | <input placeholder="Search"> |
Image alt text | getByAltText('Logo') | <img alt="Logo"> |
Tooltip (title) | getByTitle('Delete') | <button title="Delete"> |
| Test id | getByTestId('checkout') | <button data-testid="checkout"> |
| Id | By.id('email') · '#email' | <input id="email"> |
| Class | By.className('primary') · '.primary' | <button class="primary"> |
name attribute | By.name('email') | <input name="email"> |
| Tag | By.tagName('h1') | <h1>…</h1> |
| Any attribute | By.attr('role', 'dialog') | <div role="dialog"> |
data-* attribute | By.dataAttr('state', 'open') | <div data-state="open"> |
aria-* attribute | By.aria('expanded', 'true') | <button aria-expanded="true"> |
| Link text | By.linkText('Docs') | <a href="/docs">Docs</a> |
| A CSS selector | By.css(…) · any string | anything CSS can express |
| An XPath expression | By.xpath(…) | anything XPath can express |
Semantic locators
These describe an element by what it is to the user — its role, label, or text — not by where it sits in the markup. They're the most readable, and the most resilient while the name they match is stable — reach for them first for stable labels, and see Choosing a locator for when a name is dynamic or localized. Each is a getBy* method on the browser (and on any locator); the By.* form named in each heading is the same strategy as an object you can pass to find(), locator(), click(), or a filter.
Exact by default.
getByRole'sname,getByText,getByLabel,getByPlaceholder,getByAltText, andgetByTitleall match the whole (whitespace-normalised) string by default. Pass{ exact: false }to match a substring instead.
getByRole(role, options?)
Find an element by its ARIA role and, optionally, CraftDriver's practical accessible-name approximation.
<button>Sign in</button>browser.getByRole('button', { name: 'Sign in' });- A role is the kind of control an element represents —
button,link,checkbox,textbox,heading, and so on. Native elements carry an implicit role, sogetByRole('button')matches a plain<button>and<input type="submit">, not only elements with an explicitrole="button". - The name is the short label a user would recognize. CraftDriver matches the common sources:
aria-label, simplearia-labelledby, visible text, associated<label>text for form controls,value,alt, andtitle. Pass it asnameto pick one element out of several with the same role.
<!-- the role is textbox; the name comes from the associated label -->
<label for="email">Email</label>
<input id="email" type="email" />browser.getByRole('textbox', { name: 'Email' });
browser.getByRole('checkbox', { name: 'Remember me' });
browser.getByRole('link', { name: 'Learn more' });
browser.getByRole('heading', { name: 'Welcome' });
getByRoleis an approximation of browser accessible-name computation, not a full ARIA engine. If the label itself is the thing you want to select by, usegetByLabel(). To match<input name="email">by the HTMLnameattribute, useBy.name().
Options:
| Option | Type | Default | Description |
|---|---|---|---|
name | string | — | Name to match (aria-label, simple aria-labelledby, text, label, value, alt, title). |
exact | boolean | true | When false, match the name as a substring. |
includeHidden | boolean | false | Also match elements hidden from assistive tech (see below). |
By default an element is skipped when it — or any ancestor — carries hidden or aria-hidden="true", mirroring how those attributes remove a whole subtree from the accessibility tree. Set includeHidden: true to match them anyway (off-canvas menus, collapsed panels). CSS hiding such as display:none is handled separately by the locator's visibility wait, not by this check.
// Default: skip elements hidden from assistive tech (self or ancestor)
browser.getByRole('button', { name: 'Close' });
// Include hidden elements
browser.getByRole('button', { name: 'Close', includeHidden: true });
nameis a string, not a RegExp. It is matched exactly by default; passexact: falsefor a substring. (Names compile to XPath, which has no regex operator — aRegExpthrowsINVALID_ARGUMENT.) Accessible names are also locale-sensitive and sometimes dynamic —"Count is 0"becomes"Count is 1", and"Save"becomes"Guardar"in another language. Don't hardcode a name that changes or differs per language; select those elements by a stable id ordata-testidinstead.craftdriver locatorsflags a name that looks dynamic.
getByText(text, options?)
Find the innermost element whose visible text matches.
<p>Welcome back, Alice!</p>browser.getByText('Welcome back, Alice!'); // exact (default)
browser.getByText('Welcome back', { exact: false }); // substringText is whitespace-normalised — runs of spaces and line breaks collapse to a single space — before matching. For case-insensitive or non-normalised matching, use the By.text form, which also accepts caseSensitive and trim:
browser.find(By.text('submit', { caseSensitive: false }));
browser.find(By.partialText('Submit')); // lower-level alias for { exact: false }getByLabel(text, options?)
Find a form control by its associated <label> — whether the label wraps the control or points to it with for.
<label for="pw">Password</label>
<input id="pw" type="password" />browser.getByLabel('Password');
browser.getByLabel('Pass', { exact: false });getByPlaceholder(text, options?)
Find an <input> or <textarea> by its placeholder.
<input placeholder="Search products…" />browser.getByPlaceholder('Search products…');getByAltText(text, options?)
Find an <img>, <area>, or <input type="image"> by its alt text.
<img src="/logo.svg" alt="Company logo" />browser.getByAltText('Company logo');getByTitle(text, options?)
Find an element by its title attribute (the native browser tooltip).
<button title="Delete row">🗑</button>browser.getByTitle('Delete row');getByTestId(testId)
Find an element by its data-testid. Test ids are invisible to users and untouched by design changes, so they're the most stable choice for elements with no meaningful role or text — or whose accessible name is dynamic or localized. They're an escape hatch, though, not a default: reach for them when the app already ships test ids, or you deliberately add one, not ahead of a stable semantic locator.
<button data-testid="checkout">Proceed to checkout</button>browser.getByTestId('checkout');Each
getBy*method has aBy.*twin —By.role,By.text,By.labelText,By.placeholder,By.altText,By.title,By.testId— for when you're passing a locator intofind(),locator(),click(), or a filter.
Attribute and structural locators
When an element has no meaningful role or text, match it by id, class, attribute, or position. These come as By.* locators (a plain string already covers the CSS cases).
By.id(id)
<input id="email" />browser.find(By.id('email')); // same as browser.find('#email')By.css(selector)
Locate by any CSS selector — identical to passing the selector as a string.
browser.find(By.css('input[type="email"]'));
browser.find(By.css('form.login #username'));By.className(name)
<button class="primary">Save</button>browser.find(By.className('primary')); // same as browser.find('.primary')By.name(name)
Locate by the HTML name attribute.
<input name="email" />browser.find(By.name('email'));This is the name attribute — not the name option of getByRole(), which matches the accessible name.
By.tagName(tag)
<h1>Page title</h1>browser.find(By.tagName('h1')); // the first <h1>By.attr(name, value)
Locate by any attribute and value.
<div role="dialog">…</div>browser.find(By.attr('role', 'dialog')); // same as '[role="dialog"]'By.dataAttr(name, value)
Locate by a data-* attribute — pass the suffix only, data- is added for you.
<div data-state="open">…</div>browser.find(By.dataAttr('state', 'open')); // same as '[data-state="open"]'By.aria(name, value)
Locate by an aria-* attribute — pass the suffix only, aria- is added for you. Use this for a specific ARIA state or property; for role + name matching use getByRole().
<button aria-expanded="true">Menu</button>browser.find(By.aria('expanded', 'true')); // same as '[aria-expanded="true"]'By.linkText(text) · By.partialLinkText(text)
Locate an <a> by its rendered text — exact for linkText, substring for partialLinkText. These match only anchors, and compare the browser-rendered text (so CSS text-transform is respected). For non-links or richer text options, use getByText / By.text.
<a href="/docs">Documentation</a>browser.find(By.linkText('Documentation')); // exact anchor text
browser.find(By.partialLinkText('Read the')); // substring of anchor textBy.xpath(expression)
The escape hatch for queries the other strategies can't express.
browser.find(By.xpath('//button[@data-testid="submit"]'));
browser.find(By.xpath('//div[contains(@class, "error")]'));Choosing a locator
There is no single "best" locator — it depends on the page, on whether the accessible names are stable, and on what the app already exposes. One principle covers it:
Prefer the locator that is at once the most stable and the most meaningful. Semantic, user-facing locators are excellent — but only while the value they match is stable.
// ✅ Resilient — tracks what the element is
browser.getByRole('button', { name: 'Log in' }); // stable, human-readable name
browser.getByLabel('Email'); // label stays put as the field's value changes
browser.find('#login-button'); // an existing, stable id
// ❌ Fragile — breaks when markup or styling shifts
browser.find('div > div > button:nth-child(3)');
browser.find('.btn.mt-4.px-6'); // utility classes churnReach for them in this order:
getByRole({ name })/getByLabel/getByText— when the name is a stable label (Sign in,Email,Save). Reads like intent and doubles as an accessibility check.- The name is dynamic or the UI is translated → don't hardcode it. Anchor on something stable instead: an existing unique id or attribute, or a
data-testidif the app already has one. getByRolewithout a name /getByLabel— still semantic, and stable even when the field's value changes.By.id/ stableBy.css— a real id or semantic class.getByText— handy, but reads best in assertions.By.xpath— a genuine last resort.
Two rules that fall out of this:
- Never hardcode a localized or dynamic accessible name.
getByRole('button', { name: 'Save' })breaks the moment the page renders in another language, and{ name: 'Count is 0' }breaks on the next click. Select those by a stable id or test id. data-testidis an escape hatch, not a default. It is the most stable choice when the app already ships test ids, or you own the app and choose to add them — reach for it when nothing semantic and stable fits, not first.
When you're unsure whether a name is stable, let the tooling decide: craftdriver locators <selector> re-resolves every candidate against the live page and demotes a name that looks dynamic below a stable anchor.
Locators (lazy & chainable)
browser.locator(selector) returns a Locator — a lazy, re-resolving handle that supports composition, filtering, and indexed access.
Use find() for a single one-shot element, findAll() for the simple array case, and locator() when you need composition or want to pick from a list.
import { Browser } from 'craftdriver';
// Count matching elements
const n = await browser.locator('.product').count();
// Pick by index (0-based)
await browser.locator('.buy-btn').first().click();
await browser.locator('.buy-btn').last().click();
await browser.locator('.buy-btn').nth(2).click();
// Filter by text content
await browser.locator('.product').filter({ hasText: 'Pro' }).locator('.buy-btn').click();
// Filter by presence of a child locator
const hasPromo = browser.locator('.badge');
const promoCards = browser.locator('.card').filter({ has: hasPromo });
await promoCards.first().click();
// Chain into a child element
await browser.locator('.card').nth(0).locator('button').click();
// Get all matching elements as snapshot handles
const handles = await browser.locator('.product').all();
const texts = await Promise.all(handles.map((h) => h.text()));
// Simple array shortcut (no filtering)
const allButtons = await browser.findAll('.buy-btn');Scoped semantic locators
Locator also exposes getByRole / getByText / getByLabel / getByPlaceholder / getByAltText / getByTitle / getByTestId, each scoped to that locator's match — the composable equivalent of the browser.getBy*() methods above, for when you need to find something within a specific row, card, or dialog rather than anywhere on the page.
// Scope a role/text query to one product card, not the whole page
const card = browser.locator('.product-card').nth(2);
await card.getByRole('button', { name: 'Buy' }).click();
await card.getByText('In stock').expect().toBeVisible();Open Shadow DOM
CraftDriver crosses Shadow DOM with an explicit, lazy shadowRoot() boundary. Call it on the locator or element handle for the shadow host, then locate within the returned ShadowRootLocator:
const card = browser.locator('user-card#account').shadowRoot();
await card.getByRole('button', { name: 'Edit' }).click();
await card.getByLabel('Display name').fill('Alice');
await card.locator('.status').expect().toHaveText('Saved');The boundary is explicit: browser.locator('user-card .status') does not pierce the root and ordinary CSS keeps its normal tree scope. Both API styles work:
// Lazy Locator host — re-resolves the host and root for every operation.
const lazyRoot = browser.locator('#account').shadowRoot();
// ElementHandle style — browser.find() is locator-backed and auto-waits too.
const handleRoot = browser.find('#account').shadowRoot();
await handleRoot.find(By.testId('save')).click();Nested open roots compose by crossing each boundary in order:
const address = browser
.locator('checkout-shell')
.shadowRoot()
.locator('address-form')
.shadowRoot();
await address.getByPlaceholder('City').fill('Sofia');
await address.getByRole('button', { name: 'Save address' }).click();ShadowRootLocator is a search context, not an element. It provides locator(), find(), findAll(), and all getBy*() helpers; actions and assertions live on the descendant Locator/ElementHandle. Filters, indexing, auto-waiting, assertions, locator-scoped accessibility audits, Page, and Frame contexts retain the complete root-aware query chain.
The host follows normal locator cardinality: when it matches more than one element, use first(), last(), or nth() before shadowRoot() to select the intended host. Descendant count() returns 0 for no current matches, while actions and positive assertions auto-wait using the configured timeout.
Only roots exposed by the page's public host.shadowRoot getter are supported. A missing or closed root throws NO_OPEN_SHADOW_ROOT; CraftDriver does not use WebDriver as a closed-root backdoor. Detached roots restart the complete lazy query and end with DETACHED_SHADOW_ROOT only when a stable root cannot be established. Raw XPath inside a shadow root is transport-dependent; CSS and the semantic getBy*/By.* helpers are the portable choices.
The same API is covered on Chrome, Firefox, and Safari. CraftDriver uses WebDriver BiDi shadow primitives when the session supports them and falls back to the standard WebDriver Classic shadow-root endpoints otherwise.
Locator actions and state
| Method | Description |
|---|---|
click(options?) | Click the first matching visible element |
fill(text, options?) | Fill the first matching visible element |
text(options?) | Get visible text |
textContent(options?) | Alias for text() |
isVisible(options?) | Return true if a matching element is visible |
count() | Count current matches without auto-wait |
all() | Return snapshot ElementHandles for current matches |
waitFor({ state, timeout? }) | Wait for attached, detached, visible, hidden |
expect() | Element assertions scoped to this locator |
a11y | Accessibility audit scoped to this locator |
await browser.locator('.toast').waitFor({ state: 'visible' });
const shown = await browser.locator('.toast').isVisible();
const text = await browser.locator('.toast').textContent();
const audit = await browser.locator('#checkout').a11y.audit();Assertions on locators
await browser.locator('#result').expect().toHaveText('Done');
await browser.locator('.error').expect().not.toBeVisible();