SurfSense/.cursor/skills/playwright-testing/locators.md
2026-05-04 13:54:13 +05:30

713 lines
29 KiB
Markdown
Executable file

# Locators
> **When to use**: Every time you need to find an element on the page. Start here before reaching for CSS or XPath.
> **Prerequisites**: [core/configuration.md](configuration.md)
## Quick Reference
```typescript
// Priority order — use the first one that works:
page.getByRole('button', { name: 'Submit' }) // 1. Role (default)
page.getByLabel('Email address') // 2. Label (form fields)
page.getByText('Welcome back') // 3. Text (non-interactive)
page.getByPlaceholder('Search...') // 4. Placeholder
page.getByAltText('Company logo') // 5. Alt text (images)
page.getByTitle('Close dialog') // 6. Title attribute
page.getByTestId('checkout-summary') // 7. Test ID (last semantic option)
page.locator('css=.legacy-widget >> internal:role=button') // 8. CSS/XPath (last resort)
```
## Playwright 1.59 Locator Helpers
Playwright 1.59 added two useful locator-discovery helpers:
- Interactive locator picking for debugging and exploration
- `locator.normalize()` for rewriting brittle selectors toward Playwright best practices
Use these during debugging, refactors, and authoring. Do not treat them as a substitute for thinking through the most user-facing locator yourself. The best production locator is still usually `getByRole()`, `getByLabel()`, or `getByTestId()` when semantics are unavailable.
### `page.pickLocator()` For Interactive Discovery
**Use when**: You are exploring a page, debugging a selector, or migrating a legacy suite and want Playwright to suggest a locator for the element under your cursor.
**Avoid when**: Writing final production tests without review. Treat the picked locator as a starting point, not the final answer.
**TypeScript**
```typescript
import { test } from '@playwright/test';
test('pick a locator during debugging', async ({ page }) => {
await page.goto('/checkout');
const picked = await page.pickLocator();
console.log(picked);
await page.cancelPickLocator();
});
```
**JavaScript**
```javascript
const { test } = require('@playwright/test');
test('pick a locator during debugging', async ({ page }) => {
await page.goto('/checkout');
const picked = await page.pickLocator();
console.log(picked);
await page.cancelPickLocator();
});
```
Review the suggested locator before keeping it. Prefer rewriting it to `getByRole()` or another semantic locator if that makes the test clearer.
### `locator.normalize()` For Refactors
**Use when**: You inherit brittle CSS/XPath-heavy locators and want Playwright to suggest a more idiomatic form during cleanup work.
**Avoid when**: You have not yet thought through the element's semantics. A normalized locator can still be less clear than a hand-written role or label locator.
**TypeScript**
```typescript
import { test } from '@playwright/test';
test('normalize a legacy locator', async ({ page }) => {
await page.goto('/settings');
const legacy = page.locator('.settings-panel button.save');
const normalized = await legacy.normalize();
console.log(normalized);
});
```
**JavaScript**
```javascript
const { test } = require('@playwright/test');
test('normalize a legacy locator', async ({ page }) => {
await page.goto('/settings');
const legacy = page.locator('.settings-panel button.save');
const normalized = await legacy.normalize();
console.log(normalized);
});
```
Use `normalize()` as a refactoring assistant. The final test should still reflect user intent, not just "whatever selector Playwright could derive."
## Patterns
### Role-Based Locators (Default Choice)
**Use when**: Always. This is your starting point for every element.
**Avoid when**: The element has no ARIA role and adding one is outside your control.
Role-based locators mirror how assistive technology sees your page. They survive refactors, class renames, and component library swaps.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('role-based locators cover most UI elements', async ({ page }) => {
await page.goto('/dashboard');
// Buttons — matches <button>, <input type="submit">, role="button"
await page.getByRole('button', { name: 'Save changes' }).click();
// Links — matches <a href>
await page.getByRole('link', { name: 'View profile' }).click();
// Headings — use level to target specific h1-h6
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
// Text inputs — match by accessible name (label association)
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
// Checkboxes and radios
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('radio', { name: 'Monthly billing' }).click();
// Dropdowns — <select> elements
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
// Navigation landmarks
const nav = page.getByRole('navigation', { name: 'Main' });
await expect(nav.getByRole('link', { name: 'Settings' })).toBeVisible();
// Tables
const table = page.getByRole('table', { name: 'Recent orders' });
await expect(table.getByRole('row')).toHaveCount(5);
// Dialogs
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await dialog.getByRole('button', { name: 'Delete' }).click();
// Exact matching — prevents "Log" from matching "Log out"
await page.getByRole('button', { name: 'Log', exact: true }).click();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('role-based locators cover most UI elements', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Save changes' }).click();
await page.getByRole('link', { name: 'View profile' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('radio', { name: 'Monthly billing' }).click();
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
const nav = page.getByRole('navigation', { name: 'Main' });
await expect(nav.getByRole('link', { name: 'Settings' })).toBeVisible();
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await dialog.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Log', exact: true }).click();
});
```
### Label-Based Locators
**Use when**: Targeting form fields that have associated `<label>` elements or `aria-label`.
**Avoid when**: The element is not a form control. Use `getByRole` with the accessible name instead — it covers labels too.
`getByLabel` is a shortcut for form fields. It matches `<label for="">`, wrapping `<label>`, and `aria-label` / `aria-labelledby`.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('fill a registration form using labels', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Email address').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
await page.getByLabel('Confirm password').fill('s3cure!Pass');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('fill a registration form using labels', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Email address').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
await page.getByLabel('Confirm password').fill('s3cure!Pass');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
```
### Text-Based Locators
**Use when**: Targeting non-interactive content — status messages, paragraphs, banners, labels outside forms.
**Avoid when**: The element is a button, link, heading, or form field. Use `getByRole` instead.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('verify text content on the page', async ({ page }) => {
await page.goto('/order/confirmation');
// Substring match (default)
await expect(page.getByText('Order confirmed')).toBeVisible();
// Exact match — use when substring hits multiple elements
await expect(page.getByText('Order #12345', { exact: true })).toBeVisible();
// Regex match — for dynamic content patterns
await expect(page.getByText(/Order #\d+/)).toBeVisible();
// DO NOT use getByText for buttons or links:
// Bad: page.getByText('Submit')
// Good: page.getByRole('button', { name: 'Submit' })
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('verify text content on the page', async ({ page }) => {
await page.goto('/order/confirmation');
await expect(page.getByText('Order confirmed')).toBeVisible();
await expect(page.getByText('Order #12345', { exact: true })).toBeVisible();
await expect(page.getByText(/Order #\d+/)).toBeVisible();
});
```
### Test ID Locators
**Use when**: No semantic locator works — the element has no accessible role, label, or stable text. Common with custom canvas-rendered components, complex data grids, or third-party widgets.
**Avoid when**: Any semantic locator (`getByRole`, `getByLabel`, `getByText`) can identify the element. Test IDs are invisible to users and assistive technology.
Configure the attribute name once in `playwright.config`:
**TypeScript**
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-testid', // default; change to match your codebase
},
});
```
```typescript
import { test, expect } from '@playwright/test';
test('interact with a custom widget using test IDs', async ({ page }) => {
await page.goto('/analytics');
// Only when the chart component exposes no accessible roles
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
await chart.click({ position: { x: 150, y: 75 } });
await expect(page.getByTestId('chart-tooltip')).toContainText('$12,400');
});
```
**JavaScript**
```javascript
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
use: {
testIdAttribute: 'data-testid',
},
});
```
```javascript
const { test, expect } = require('@playwright/test');
test('interact with a custom widget using test IDs', async ({ page }) => {
await page.goto('/analytics');
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
await chart.click({ position: { x: 150, y: 75 } });
await expect(page.getByTestId('chart-tooltip')).toContainText('$12,400');
});
```
### CSS/XPath — Last Resort
**Use when**: You have zero control over the markup, no test IDs, no accessible names, and no way to add them. Legacy apps with generated class names and no semantic HTML.
**Avoid when**: Any other locator type works. Always.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('legacy app with no semantic markup', async ({ page }) => {
await page.goto('/legacy-admin');
// CSS — prefer short, structural selectors over fragile class chains
await page.locator('table.report-grid td:has-text("Overdue")').first().click();
// XPath — only when CSS cannot express the query (e.g., text + ancestor traversal)
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click();
// Combine CSS with Playwright pseudo-selectors for resilience
await page.locator('.sidebar >> role=button[name="Expand"]').click();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('legacy app with no semantic markup', async ({ page }) => {
await page.goto('/legacy-admin');
await page.locator('table.report-grid td:has-text("Overdue")').first().click();
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click();
await page.locator('.sidebar >> role=button[name="Expand"]').click();
});
```
### Locator Chaining and Filtering
**Use when**: A single locator matches multiple elements and you need to narrow down by context, content, or position.
**Avoid when**: A direct `getByRole` with `name` already uniquely identifies the element.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('chaining and filtering locators', async ({ page }) => {
await page.goto('/products');
// Chain: scope a locator within another
const productCard = page.getByRole('listitem').filter({ hasText: 'Running Shoes' });
await productCard.getByRole('button', { name: 'Add to cart' }).click();
// Filter by child locator — more precise than hasText
const row = page.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Premium Plan' }),
});
await row.getByRole('button', { name: 'Upgrade' }).click();
// Filter with hasNot — exclude elements containing a child
const availableItems = page.getByRole('listitem').filter({
hasNot: page.getByText('Sold out'),
});
await expect(availableItems).toHaveCount(3);
// Filter with hasNotText — exclude by text content
const nonFeatured = page.getByRole('listitem').filter({
hasNotText: 'Featured',
});
// Positional: nth, first, last — use sparingly, only when order is stable
const thirdItem = page.getByRole('listitem').nth(2); // 0-indexed
const firstItem = page.getByRole('listitem').first();
const lastItem = page.getByRole('listitem').last();
// Combining multiple filters
const activeAdminRow = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'Admin' }) })
.filter({ has: page.getByText('Active') });
await expect(activeAdminRow).toHaveCount(1);
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('chaining and filtering locators', async ({ page }) => {
await page.goto('/products');
const productCard = page.getByRole('listitem').filter({ hasText: 'Running Shoes' });
await productCard.getByRole('button', { name: 'Add to cart' }).click();
const row = page.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Premium Plan' }),
});
await row.getByRole('button', { name: 'Upgrade' }).click();
const availableItems = page.getByRole('listitem').filter({
hasNot: page.getByText('Sold out'),
});
await expect(availableItems).toHaveCount(3);
const thirdItem = page.getByRole('listitem').nth(2);
const firstItem = page.getByRole('listitem').first();
const lastItem = page.getByRole('listitem').last();
const activeAdminRow = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'Admin' }) })
.filter({ has: page.getByText('Active') });
await expect(activeAdminRow).toHaveCount(1);
});
```
### Frame Locators
**Use when**: Interacting with content inside `<iframe>` or `<frame>` elements — payment widgets, embedded editors, third-party widgets.
**Avoid when**: The content is in the main frame or a Shadow DOM (use piercing instead).
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('interact with content inside an iframe', async ({ page }) => {
await page.goto('/checkout');
// Locate the iframe, then use normal locators inside it
const paymentFrame = page.frameLocator('iframe[title="Payment"]');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiration').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await paymentFrame.getByRole('button', { name: 'Pay' }).click();
// Nested iframes — chain frameLocator calls
const nestedFrame = page
.frameLocator('#outer-frame')
.frameLocator('#inner-frame');
await expect(nestedFrame.getByText('Payment confirmed')).toBeVisible();
// Frame by nth index — when no better selector exists
const secondFrame = page.frameLocator('iframe').nth(1);
await expect(secondFrame.getByRole('heading')).toBeVisible();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('interact with content inside an iframe', async ({ page }) => {
await page.goto('/checkout');
const paymentFrame = page.frameLocator('iframe[title="Payment"]');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiration').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await paymentFrame.getByRole('button', { name: 'Pay' }).click();
const nestedFrame = page
.frameLocator('#outer-frame')
.frameLocator('#inner-frame');
await expect(nestedFrame.getByText('Payment confirmed')).toBeVisible();
const secondFrame = page.frameLocator('iframe').nth(1);
await expect(secondFrame.getByRole('heading')).toBeVisible();
});
```
### Shadow DOM Piercing
**Use when**: Targeting elements inside web components that use Shadow DOM (custom elements, design system components, Salesforce Lightning).
**Avoid when**: The element is in a regular DOM or iframe.
Playwright pierces open Shadow DOM automatically with `locator()`. The `getByRole` / `getByText` family also pierces shadow roots by default.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('interact with Shadow DOM elements', async ({ page }) => {
await page.goto('/design-system-demo');
// getByRole automatically pierces open Shadow DOM — just use it normally
await page.getByRole('button', { name: 'Toggle menu' }).click();
// locator() with CSS also pierces shadow roots by default
await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();
// Chain into nested shadow DOMs
await page
.locator('my-app')
.locator('my-sidebar')
.getByRole('link', { name: 'Profile' })
.click();
// If you explicitly need non-piercing behavior (rare), use css=light/
// await page.locator('css:light=.outer-only').click();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('interact with Shadow DOM elements', async ({ page }) => {
await page.goto('/design-system-demo');
await page.getByRole('button', { name: 'Toggle menu' }).click();
await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();
await page
.locator('my-app')
.locator('my-sidebar')
.getByRole('link', { name: 'Profile' })
.click();
});
```
### Dynamic Content — Waiting for Elements
**Use when**: Elements appear after API calls, animations, lazy loading, or route transitions.
**Avoid when**: The element is already on the page. Playwright auto-waits on actions, so explicit waits are rarely needed.
Never use `page.waitForTimeout()`. Use auto-waiting assertions or explicit event waits.
**TypeScript**
```typescript
import { test, expect } from '@playwright/test';
test('handle dynamic content without manual waits', async ({ page }) => {
await page.goto('/search');
// Auto-waiting: actions like click(), fill() wait for the element automatically
await page.getByRole('textbox', { name: 'Search' }).fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
// Web-first assertion: auto-retries until timeout (default 5s)
await expect(page.getByRole('listitem')).toHaveCount(10);
// Wait for a specific element to appear after an async operation
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
// Wait for loading indicators to disappear
await expect(page.getByRole('progressbar')).toBeHidden();
// Wait for network-dependent content: wait for response then assert
const responsePromise = page.waitForResponse('**/api/search*');
await page.getByRole('button', { name: 'Load more' }).click();
await responsePromise;
await expect(page.getByRole('listitem')).toHaveCount(20);
// Wait for URL change after navigation
await page.getByRole('link', { name: 'First result' }).click();
await page.waitForURL('**/results/**');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
```
**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');
test('handle dynamic content without manual waits', async ({ page }) => {
await page.goto('/search');
await page.getByRole('textbox', { name: 'Search' }).fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).toHaveCount(10);
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();
const responsePromise = page.waitForResponse('**/api/search*');
await page.getByRole('button', { name: 'Load more' }).click();
await responsePromise;
await expect(page.getByRole('listitem')).toHaveCount(20);
await page.getByRole('link', { name: 'First result' }).click();
await page.waitForURL('**/results/**');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
```
## Decision Guide
| Element Type | Recommended Locator | Example | Why |
|---|---|---|---|
| Button | `getByRole('button', { name })` | `getByRole('button', { name: 'Submit' })` | Matches `<button>`, `<input type="submit">`, `role="button"` |
| Link | `getByRole('link', { name })` | `getByRole('link', { name: 'Home' })` | Matches any `<a href>` regardless of styling |
| Text input | `getByRole('textbox', { name })` | `getByRole('textbox', { name: 'Email' })` | Matches by accessible name (label) |
| Password input | `getByLabel()` | `getByLabel('Password')` | Password fields have no distinct role; label is the best match |
| Checkbox | `getByRole('checkbox', { name })` | `getByRole('checkbox', { name: 'Agree' })` | Also use `.check()` / `.uncheck()` instead of `.click()` |
| Radio button | `getByRole('radio', { name })` | `getByRole('radio', { name: 'Express' })` | Group radios with `getByRole('radiogroup')` |
| Select/dropdown | `getByRole('combobox', { name })` | `getByRole('combobox', { name: 'Country' })` | Native `<select>` maps to combobox role |
| Custom dropdown | `getByRole('listbox')` + `getByRole('option')` | Click trigger, then `getByRole('option', { name })` | ARIA listbox pattern for custom dropdowns |
| Heading | `getByRole('heading', { name, level })` | `getByRole('heading', { name: 'Dashboard', level: 2 })` | Use `level` to distinguish h1-h6 |
| Table | `getByRole('table', { name })` | `getByRole('table', { name: 'Users' })` | Chain with `getByRole('row')`, `getByRole('cell')` |
| Table row | `getByRole('row').filter({ has })` | `.filter({ has: getByRole('cell', { name: 'Jane' }) })` | Filter rows by cell content |
| Navigation | `getByRole('navigation', { name })` | `getByRole('navigation', { name: 'Main' })` | Matches `<nav>` with `aria-label` |
| Dialog/modal | `getByRole('dialog', { name })` | `getByRole('dialog', { name: 'Confirm' })` | Scope all dialog interactions under this |
| Tab | `getByRole('tab', { name })` | `getByRole('tab', { name: 'Settings' })` | Use with `getByRole('tabpanel')` |
| Image | `getByAltText()` | `getByAltText('User avatar')` | Matches the `alt` attribute |
| Form field (any) | `getByLabel()` | `getByLabel('Date of birth')` | Fallback when role is ambiguous (date pickers, custom inputs) |
| Static text | `getByText()` | `getByText('No results found')` | Non-interactive content only |
| No semantic markup | `getByTestId()` | `getByTestId('sparkline-chart')` | Last semantic option before CSS |
| Iframe content | `frameLocator()` then any locator | `frameLocator('#payment').getByLabel('Card')` | Required for cross-frame access |
| Shadow DOM | `getByRole()` / `locator()` | Works automatically | Playwright pierces open shadow roots by default |
## Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| `page.locator('.btn-primary')` | Breaks when CSS classes change (renames, CSS modules, Tailwind) | `page.getByRole('button', { name: 'Save' })` |
| `page.locator('#submit-btn')` | IDs are implementation details; often auto-generated | `page.getByRole('button', { name: 'Submit' })` |
| `page.locator('div > span:nth-child(3)')` | Breaks on any DOM restructure | `page.getByText('Expected content')` or `getByTestId()` |
| `page.locator('xpath=//div[@class="form"]//input[2]')` | Fragile, unreadable, position-dependent | `page.getByLabel('Last name')` |
| `page.getByText('Submit')` for a button | Text locators don't assert the element is interactive | `page.getByRole('button', { name: 'Submit' })` |
| `page.locator('.item').nth(0)` on dynamic lists | Index changes when items are added/removed/reordered | `.filter({ hasText: 'Specific item' })` |
| `page.getByText('Aceptar')` hardcoded i18n text | Fails when locale changes | `page.getByRole('button', { name: /accept/i })` or `getByTestId('confirm-btn')` |
| `await page.waitForTimeout(3000)` | Arbitrary delay; too slow in fast environments, too short in slow ones | `await expect(locator).toBeVisible()` |
| `page.locator('.card').locator('.card-title').locator('a')` | Deep CSS chaining breaks on any structural change | `page.getByRole('link', { name: 'Card title text' })` |
| `page.$('selector')` (ElementHandle API) | Returns a snapshot, not auto-waiting; deprecated pattern | `page.locator('selector')` — locators are lazy and auto-wait |
| `page.locator('text=Click here')` | Legacy text selector syntax | `page.getByText('Click here')` or `getByRole` with name |
| Multiple locators for one element in sequence | Each `locator()` call in a chain restarts the search | Store as variable: `const btn = page.getByRole('button', { name: 'Save' })` |
## Troubleshooting
### "strict mode violation" — locator matches multiple elements
**Cause**: Your locator is not specific enough and Playwright refuses to pick one for you.
```typescript
// Error: locator.click: strict mode violation, getByRole('button') resolved to 5 elements
await page.getByRole('button').click(); // Too broad
// Fix 1: Add a name filter
await page.getByRole('button', { name: 'Save' }).click();
// Fix 2: Scope within a parent
await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
// Fix 3: Use exact matching when names are substrings of each other
await page.getByRole('button', { name: 'Save', exact: true }).click();
// Fix 4: Use filter to narrow down
await page.getByRole('button').filter({ hasText: 'Save draft' }).click();
// Debug: see what matched
console.log(await page.getByRole('button').all()); // list all matches
```
### Element exists but locator times out
**Cause**: The element is inside an iframe, Shadow DOM, or is obscured/hidden.
```typescript
// Check if element is inside an iframe
const frame = page.frameLocator('iframe');
await frame.getByRole('button', { name: 'Submit' }).click();
// Check if element is in a Shadow DOM — getByRole pierces automatically,
// but page.$() and CSS selectors may not. Switch to getByRole.
// Check if element is hidden (display: none, visibility: hidden, opacity: 0)
// Use toBeVisible() to confirm, or toBeAttached() if hidden is expected
await expect(page.getByRole('button', { name: 'Submit' })).toBeAttached();
```
### `getByRole` doesn't find the element
**Cause**: The element's implicit ARIA role doesn't match what you expect, or it has no role.
```typescript
// Debug: inspect the accessibility tree
const snapshot = await page.accessibility.snapshot();
console.log(JSON.stringify(snapshot, null, 2));
// Common mismatches:
// - <div onclick="..."> has no button role → add role="button" or use <button>
// - <input type="text"> with no label has no accessible name → add <label> or aria-label
// - <a> without href has no link role → add href or role="link"
// Fallback chain: try getByLabel → getByText → getByTestId → locator()
```
## Related
- [core/locator-strategy.md](locator-strategy.md) — deep-dive decision framework for choosing locator strategies at the project level
- [core/assertions-and-waiting.md](assertions-and-waiting.md) — pair locators with web-first assertions
- [pom/page-object-model.md](../pom/page-object-model.md) — encapsulate locators in page objects
- [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) — advanced iframe and Shadow DOM patterns
- [core/i18n-and-localization.md](i18n-and-localization.md) — locator strategies for internationalized apps