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 locators that describe an element the way a person perceives it — its role, label, or visible text — plus test IDs for anything ambiguous. They keep working when the surrounding markup changes. Drop to CSS for structural matching, and to XPath only when nothing else fits.
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, so reach for them first. 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 });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.
<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
Prefer locators that describe intent; avoid ones that pin to incidental structure.
// ✅ Resilient — tracks what the element is
browser.getByRole('button', { name: 'Log in' });
browser.getByLabel('Email');
browser.getByTestId('checkout');
browser.find('#login-button');
// ❌ Fragile — breaks when markup or styling shifts
browser.find('div > div > button:nth-child(3)');
browser.find('.btn.mt-4.px-6'); // utility classes churnA rough order of preference:
getByTestId > getByRole({ name }) > getByLabel >
getByText > By.id / By.css > By.xpathThe first few say what the element is; the last two pin to how the page is built and break more easily. When an element is genuinely hard to select, add a data-testid to it rather than reaching for a brittle CSS or XPath path.
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();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();