mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
feat: add e2e test cursor skill
This commit is contained in:
parent
2e1b9b5582
commit
4ac994792b
45 changed files with 39848 additions and 0 deletions
586
.cursor/skills/playwright-testing/locator-strategy.md
Executable file
586
.cursor/skills/playwright-testing/locator-strategy.md
Executable file
|
|
@ -0,0 +1,586 @@
|
|||
# Choosing a Locator Strategy
|
||||
|
||||
> **When to use**: When deciding which Playwright locator method to use for an element
|
||||
|
||||
## Quick Answer
|
||||
|
||||
Use `getByRole()` for everything that has a semantic HTML role (buttons, links, headings, form fields, dialogs). Fall back to `getByLabel()` for form fields, `getByText()` for plain content, and `getByTestId()` only as a last resort for custom components with no accessible role.
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```
|
||||
Start: You need to locate an element
|
||||
|
|
||||
v
|
||||
Does the element have a semantic role?
|
||||
(button, link, heading, textbox, checkbox, combobox, dialog, img, row, cell, navigation...)
|
||||
|
|
||||
+-- YES --> Use getByRole('role', { name: 'accessible name' })
|
||||
| |
|
||||
| +-- Need to narrow scope? Chain from a parent role locator:
|
||||
| getByRole('navigation').getByRole('link', { name: '...' })
|
||||
|
|
||||
+-- NO
|
||||
|
|
||||
v
|
||||
Is it a form field with a visible <label>?
|
||||
|
|
||||
+-- YES --> Use getByLabel('label text')
|
||||
|
|
||||
+-- NO
|
||||
|
|
||||
v
|
||||
Is it static text content (paragraph, span, div with text)?
|
||||
|
|
||||
+-- YES --> Use getByText('text content')
|
||||
| Prefer { exact: true } when text is short or common
|
||||
|
|
||||
+-- NO
|
||||
|
|
||||
v
|
||||
Does it have a placeholder?
|
||||
|
|
||||
+-- YES --> Use getByPlaceholder('placeholder text')
|
||||
| (Less preferred -- placeholders disappear on input)
|
||||
|
|
||||
+-- NO
|
||||
|
|
||||
v
|
||||
Does it have a title attribute or alt text?
|
||||
|
|
||||
+-- YES --> Use getByTitle('...') or getByAltText('...')
|
||||
|
|
||||
+-- NO
|
||||
|
|
||||
v
|
||||
Add data-testid="..." to the markup
|
||||
Use getByTestId('identifier')
|
||||
|
|
||||
+--> NEVER fall back to CSS selectors or XPath.
|
||||
Fix the markup instead.
|
||||
```
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Element Type | Recommended Locator | Fallback | Example |
|
||||
|---|---|---|---|
|
||||
| Button | `getByRole('button', { name })` | `getByText()` if role missing | `getByRole('button', { name: 'Submit' })` |
|
||||
| Link | `getByRole('link', { name })` | `getByText()` for anchor text | `getByRole('link', { name: 'Sign up' })` |
|
||||
| Text input | `getByLabel('...')` | `getByRole('textbox', { name })` | `getByLabel('Email address')` |
|
||||
| Checkbox | `getByRole('checkbox', { name })` | `getByLabel()` | `getByRole('checkbox', { name: 'Accept terms' })` |
|
||||
| Radio button | `getByRole('radio', { name })` | `getByLabel()` | `getByRole('radio', { name: 'Express shipping' })` |
|
||||
| Dropdown / Select | `getByRole('combobox', { name })` | `getByLabel()` | `getByLabel('Country')` |
|
||||
| Heading | `getByRole('heading', { name, level })` | `getByText()` | `getByRole('heading', { name: 'Dashboard', level: 1 })` |
|
||||
| Nav link | chain: `getByRole('navigation').getByRole('link', { name })` | scope with `locator('nav')` | see detailed example below |
|
||||
| Table cell | chain: `getByRole('row').filter().getByRole('cell')` | `locator('td')` scoped | see detailed example below |
|
||||
| Image | `getByRole('img', { name })` | `getByAltText()` | `getByRole('img', { name: 'Company logo' })` |
|
||||
| Modal / Dialog | `getByRole('dialog')` then chain within | `locator('[role="dialog"]')` | `getByRole('dialog').getByRole('button', { name: 'Confirm' })` |
|
||||
| Dynamic list item | `.filter({ hasText })` or `.filter({ has })` | `nth()` as last resort | `getByRole('listitem').filter({ hasText: 'Milk' })` |
|
||||
| Custom component | `getByTestId('...')` | Add `data-testid` to markup | `getByTestId('color-picker')` |
|
||||
|
||||
## Detailed Analysis
|
||||
|
||||
### Tier 1: `getByRole()` -- Use by Default
|
||||
|
||||
The strongest locator. It mirrors how assistive technology and real users perceive the page.
|
||||
|
||||
**Pros**
|
||||
- Resilient to markup refactors (class names, tag changes)
|
||||
- Enforces accessible markup -- if the locator breaks, your accessibility broke too
|
||||
- Works across frameworks (React, Vue, Angular, plain HTML)
|
||||
- Supports filtering by `name`, `level`, `checked`, `pressed`, `expanded`, `selected`
|
||||
|
||||
**Cons**
|
||||
- Requires the element to have a valid ARIA role (implicit or explicit)
|
||||
- Can match multiple elements when names are duplicated -- scope with chaining
|
||||
|
||||
**When it fails**: Custom components with `<div>` soup and no ARIA roles. Fix the component first; add `getByTestId()` only if you cannot change the markup.
|
||||
|
||||
---
|
||||
|
||||
### Tier 2: `getByLabel()` -- Form Fields
|
||||
|
||||
Queries by the associated `<label>` text. This is often the most readable locator for form fields.
|
||||
|
||||
**Pros**
|
||||
- Extremely readable: `getByLabel('Password')` tells you exactly what field
|
||||
- Works with `<label for="...">`, wrapping `<label>`, and `aria-labelledby`
|
||||
|
||||
**Cons**
|
||||
- Only works for form elements with labels
|
||||
- Breaks if someone changes label text (but that is usually intentional)
|
||||
|
||||
**Use over `getByRole('textbox')`** when the label text is clear and unique. Use `getByRole()` when you need to differentiate between multiple fields with similar labels by role type.
|
||||
|
||||
---
|
||||
|
||||
### Tier 3: `getByText()` -- Static Content
|
||||
|
||||
Finds elements by their visible text content.
|
||||
|
||||
**Pros**
|
||||
- Intuitive for non-interactive text (paragraphs, spans, badges, status messages)
|
||||
- Supports exact and substring matching
|
||||
|
||||
**Cons**
|
||||
- Fragile if text is dynamic, translated, or duplicated
|
||||
- Can match parent elements unintentionally -- use `{ exact: true }` or scope the query
|
||||
|
||||
**Rule of thumb**: Use `getByText()` for assertions and content verification, not for interactive elements. Interactive elements should use `getByRole()`.
|
||||
|
||||
---
|
||||
|
||||
### Tier 4: `getByPlaceholder()` -- Inputs Without Labels
|
||||
|
||||
Locates by placeholder attribute value.
|
||||
|
||||
**Pros**
|
||||
- Works when labels are missing (search bars, minimal UIs)
|
||||
|
||||
**Cons**
|
||||
- Placeholders disappear when the user types -- poor UX foundation
|
||||
- Signals missing accessibility (no label)
|
||||
|
||||
**Treat as a yellow flag**: If you use this, consider filing a ticket to add a proper label.
|
||||
|
||||
---
|
||||
|
||||
### Tier 5: `getByTestId()` -- Last Resort
|
||||
|
||||
Locates by `data-testid` attribute.
|
||||
|
||||
**Pros**
|
||||
- Fully decoupled from user-facing text and structure
|
||||
- Stable under UI redesigns
|
||||
|
||||
**Cons**
|
||||
- Invisible to users and assistive technology
|
||||
- Pollutes production markup (unless stripped in build)
|
||||
- Tells you nothing about what the element looks like or does
|
||||
|
||||
**Use only when**: The component has no semantic role, no label, no text, and you cannot change the markup. Common cases: canvas elements, third-party widgets, complex custom components.
|
||||
|
||||
---
|
||||
|
||||
### Never Use: Raw CSS Selectors or XPath
|
||||
|
||||
```
|
||||
// DO NOT do this
|
||||
page.locator('.btn-primary'); // class names change
|
||||
page.locator('#submit-btn'); // IDs are brittle
|
||||
page.locator('div > span:nth-child(2)'); // structure changes break this
|
||||
page.locator('xpath=//div[@class="foo"]'); // unreadable, fragile
|
||||
```
|
||||
|
||||
If you are reaching for a CSS selector, stop. Walk back up the flowchart and find a semantic locator. If none exists, add `data-testid`.
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### 1. Buttons
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Standard button
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Icon-only button (uses aria-label)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Button inside a specific section
|
||||
await page.getByRole('region', { name: 'Billing' })
|
||||
.getByRole('button', { name: 'Update' }).click();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
// Standard button
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Icon-only button (uses aria-label)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Button inside a specific section
|
||||
await page.getByRole('region', { name: 'Billing' })
|
||||
.getByRole('button', { name: 'Update' }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Links
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Standard link
|
||||
await page.getByRole('link', { name: 'Sign up' }).click();
|
||||
|
||||
// Link inside navigation
|
||||
await page.getByRole('navigation')
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
// Link with exact match (avoid partial hits)
|
||||
await page.getByRole('link', { name: 'Log in', exact: true }).click();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByRole('link', { name: 'Sign up' }).click();
|
||||
|
||||
await page.getByRole('navigation')
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
await page.getByRole('link', { name: 'Log in', exact: true }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Text Inputs
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Input with a visible label -- preferred
|
||||
await page.getByLabel('Email address').fill('user@example.com');
|
||||
|
||||
// When multiple textboxes exist and you need role specificity
|
||||
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');
|
||||
|
||||
// Textarea
|
||||
await page.getByLabel('Message').fill('Hello, world');
|
||||
|
||||
// Search input (role = searchbox)
|
||||
await page.getByRole('searchbox', { name: 'Search' }).fill('playwright');
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByLabel('Email address').fill('user@example.com');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');
|
||||
|
||||
await page.getByLabel('Message').fill('Hello, world');
|
||||
|
||||
await page.getByRole('searchbox', { name: 'Search' }).fill('playwright');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Checkboxes and Radios
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Checkbox
|
||||
await page.getByRole('checkbox', { name: 'Accept terms' }).check();
|
||||
|
||||
// Verify checked state
|
||||
await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();
|
||||
|
||||
// Radio button
|
||||
await page.getByRole('radio', { name: 'Express shipping' }).check();
|
||||
|
||||
// Radio within a group (fieldset with legend)
|
||||
await page.getByRole('group', { name: 'Shipping method' })
|
||||
.getByRole('radio', { name: 'Express' }).check();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByRole('checkbox', { name: 'Accept terms' }).check();
|
||||
|
||||
await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();
|
||||
|
||||
await page.getByRole('radio', { name: 'Express shipping' }).check();
|
||||
|
||||
await page.getByRole('group', { name: 'Shipping method' })
|
||||
.getByRole('radio', { name: 'Express' }).check();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Dropdowns and Selects
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Native <select> element
|
||||
await page.getByLabel('Country').selectOption('Canada');
|
||||
|
||||
// Custom combobox (ARIA combobox role)
|
||||
await page.getByRole('combobox', { name: 'Country' }).click();
|
||||
await page.getByRole('option', { name: 'Canada' }).click();
|
||||
|
||||
// Listbox pattern
|
||||
await page.getByRole('combobox', { name: 'Font size' }).click();
|
||||
await page.getByRole('listbox').getByRole('option', { name: '16px' }).click();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByLabel('Country').selectOption('Canada');
|
||||
|
||||
await page.getByRole('combobox', { name: 'Country' }).click();
|
||||
await page.getByRole('option', { name: 'Canada' }).click();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Font size' }).click();
|
||||
await page.getByRole('listbox').getByRole('option', { name: '16px' }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Headings
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Specific heading level
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
|
||||
|
||||
// Any heading with that name (when level does not matter)
|
||||
await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
|
||||
|
||||
// Heading within a section
|
||||
await page.getByRole('region', { name: 'Sidebar' })
|
||||
.getByRole('heading', { name: 'Categories' });
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
|
||||
|
||||
await page.getByRole('region', { name: 'Sidebar' })
|
||||
.getByRole('heading', { name: 'Categories' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Navigation Items
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Link inside the main nav
|
||||
await page.getByRole('navigation')
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
// When there are multiple navs, narrow by aria-label
|
||||
await page.getByRole('navigation', { name: 'Main menu' })
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
// Breadcrumb navigation
|
||||
await page.getByRole('navigation', { name: 'Breadcrumb' })
|
||||
.getByRole('link', { name: 'Products' }).click();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByRole('navigation')
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
await page.getByRole('navigation', { name: 'Main menu' })
|
||||
.getByRole('link', { name: 'Pricing' }).click();
|
||||
|
||||
await page.getByRole('navigation', { name: 'Breadcrumb' })
|
||||
.getByRole('link', { name: 'Products' }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Table Cells
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Find a cell in a specific row
|
||||
await page.getByRole('row', { name: /Jane Smith/ })
|
||||
.getByRole('cell', { name: '$120.00' });
|
||||
|
||||
// Click an action button in a specific row
|
||||
await page.getByRole('row', { name: /Jane Smith/ })
|
||||
.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// Filter rows using .filter() for complex matching
|
||||
const row = page.getByRole('row').filter({ hasText: 'Pending' });
|
||||
await row.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
// Verify table header exists
|
||||
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByRole('row', { name: /Jane Smith/ })
|
||||
.getByRole('cell', { name: '$120.00' });
|
||||
|
||||
await page.getByRole('row', { name: /Jane Smith/ })
|
||||
.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: 'Pending' });
|
||||
await row.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Images
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Image with alt text
|
||||
await expect(page.getByRole('img', { name: 'Company logo' })).toBeVisible();
|
||||
|
||||
// Alternative: getByAltText (same result, less preferred)
|
||||
await expect(page.getByAltText('Company logo')).toBeVisible();
|
||||
|
||||
// Avatar image inside a card
|
||||
await page.locator('article').filter({ hasText: 'Jane Smith' })
|
||||
.getByRole('img', { name: "Jane Smith's avatar" });
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await expect(page.getByRole('img', { name: 'Company logo' })).toBeVisible();
|
||||
|
||||
await expect(page.getByAltText('Company logo')).toBeVisible();
|
||||
|
||||
await page.locator('article').filter({ hasText: 'Jane Smith' })
|
||||
.getByRole('img', { name: "Jane Smith's avatar" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Custom Components (No ARIA Role)
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Third-party color picker with no semantic role
|
||||
await page.getByTestId('color-picker').click();
|
||||
|
||||
// Custom drag-and-drop zone
|
||||
await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });
|
||||
|
||||
// Canvas-based chart
|
||||
const chart = page.getByTestId('revenue-chart');
|
||||
await expect(chart).toBeVisible();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
await page.getByTestId('color-picker').click();
|
||||
|
||||
await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });
|
||||
|
||||
const chart = page.getByTestId('revenue-chart');
|
||||
await expect(chart).toBeVisible();
|
||||
```
|
||||
|
||||
**Before reaching for `getByTestId`, ask yourself**: Can I add `role` and `aria-label` to this component instead? If yes, do that and use `getByRole()`.
|
||||
|
||||
---
|
||||
|
||||
### 11. Dynamic Lists
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Filter a list item by text
|
||||
const item = page.getByRole('listitem').filter({ hasText: 'Milk' });
|
||||
await item.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
// Filter by a child locator
|
||||
const card = page.locator('.product-card').filter({
|
||||
has: page.getByText('Out of stock'),
|
||||
});
|
||||
await expect(card).toHaveCount(3);
|
||||
|
||||
// Count items in a list
|
||||
await expect(page.getByRole('listitem')).toHaveCount(5);
|
||||
|
||||
// Iterate over list items for complex assertions
|
||||
for (const item of await page.getByRole('listitem').all()) {
|
||||
await expect(item).toContainText('$');
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
const item = page.getByRole('listitem').filter({ hasText: 'Milk' });
|
||||
await item.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
const card = page.locator('.product-card').filter({
|
||||
has: page.getByText('Out of stock'),
|
||||
});
|
||||
await expect(card).toHaveCount(3);
|
||||
|
||||
await expect(page.getByRole('listitem')).toHaveCount(5);
|
||||
|
||||
for (const item of await page.getByRole('listitem').all()) {
|
||||
await expect(item).toContainText('$');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Modals and Dialogs
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
// Wait for dialog to appear, then interact within it
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Fill a form inside a dialog
|
||||
const modal = page.getByRole('dialog', { name: 'Edit profile' });
|
||||
await modal.getByLabel('Display name').fill('Jane');
|
||||
await modal.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Verify dialog closed
|
||||
await expect(dialog).toBeHidden();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog', { name: 'Edit profile' });
|
||||
await modal.getByLabel('Display name').fill('Jane');
|
||||
await modal.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Why It Fails | Use Instead |
|
||||
|---|---|---|
|
||||
| `page.locator('.btn-primary')` | Class names change during refactors and redesigns | `getByRole('button', { name: '...' })` |
|
||||
| `page.locator('#email-input')` | IDs are implementation details, not user-visible | `getByLabel('Email')` |
|
||||
| `page.locator('div > form > input:first-child')` | Any structural change breaks the selector | `getByLabel('...')` or `getByRole('textbox', { name: '...' })` |
|
||||
| `page.locator('[data-testid="submit"]')` | Raw CSS for test IDs -- use the built-in method | `getByTestId('submit')` |
|
||||
| `page.getByText('Submit')` for a button | Matches any element with that text, not just the button | `getByRole('button', { name: 'Submit' })` |
|
||||
| `page.locator('button').nth(2)` | Index-based -- breaks when order changes | `getByRole('button', { name: '...' })` |
|
||||
|
||||
## Scoping Strategy: When Multiple Elements Match
|
||||
|
||||
When a locator matches more than one element, narrow scope rather than using `nth()`:
|
||||
|
||||
```typescript
|
||||
// BAD: fragile index
|
||||
page.getByRole('button', { name: 'Edit' }).nth(0);
|
||||
|
||||
// GOOD: scope to a parent section
|
||||
page.getByRole('region', { name: 'Billing' })
|
||||
.getByRole('button', { name: 'Edit' });
|
||||
|
||||
// GOOD: scope to a table row
|
||||
page.getByRole('row', { name: /Order #1234/ })
|
||||
.getByRole('button', { name: 'Edit' });
|
||||
|
||||
// GOOD: scope with filter
|
||||
page.locator('article').filter({ hasText: 'Draft' })
|
||||
.getByRole('button', { name: 'Edit' });
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Playwright Locators documentation](https://playwright.dev/docs/locators)
|
||||
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- [ARIA Roles reference (MDN)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
|
||||
Loading…
Add table
Add a link
Reference in a new issue