| name | Playwright Locator filter & Visibility |
| description | Teaches the agent to build resilient Playwright locators with .filter (hasText/has/hasNot), narrow lists, and reason correctly about visibility, waitFor states, and timeouts. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["playwright","locator","filter","visibility","waitfor","hastext","resilient-locators","auto-wait"] |
| testingTypes | ["e2e","integration"] |
| frameworks | ["playwright"] |
| languages | ["typescript"] |
| domains | ["web"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
Playwright Locator filter & Visibility
This skill makes the agent write locators that survive UI churn: role/label-first selection, narrowed with .filter({ hasText, has, hasNot }) instead of brittle CSS/XPath, and correct reasoning about visibility — knowing when to use the auto-waiting expect(...).toBeVisible() versus the imperative isVisible() / waitFor(). The recurring theme: locators are lazy and re-resolve on every action, and assertions auto-wait, so explicit sleeps and one-shot isVisible() checks are almost always wrong.
Use this skill when a locator is flaky, matches multiple elements, depends on text or a child, or when the agent is tempted to add waitForTimeout.
Core Principles
- Locators are lazy. A
Locator is a query, not an element. It re-resolves every time you act on it, so it tolerates re-renders. Define it once, use it repeatedly.
- Filter, do not concatenate selectors. Start from a semantic locator (
getByRole) and narrow with .filter({ hasText }), .filter({ has }), or .filter({ hasNot }) — not a long CSS chain.
expect(locator).toBeVisible() auto-waits; locator.isVisible() does not. The assertion polls until the timeout; isVisible() returns a boolean right now with no waiting.
- Never assert on a
count() snapshot for dynamic lists. Use await expect(locator).toHaveCount(n) which retries; (await loc.count()) === n is a race.
- Prefer waiting for state over
waitForTimeout. locator.waitFor({ state: 'visible' }) (or just acting on the locator) replaces arbitrary sleeps.
- Scope timeouts at the assertion, not globally — pass
{ timeout } to the specific expect/action that genuinely needs longer.
Workflow / Patterns
Pattern 1 — filter({ hasText }) to pick one row out of many
The classic case: a table/list where rows share structure but differ by text.
import { test, expect } from '@playwright/test';
test('acts on the right row via hasText', async ({ page }) => {
await page.goto('https://example.com/orders');
const row = page.getByRole('row').filter({ hasText: 'ORD-1042' });
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Cancel' }).click();
const refunded = page.getByRole('row').filter({ hasText: /refunded/i });
await expect(refunded).toHaveCount(1);
});
Pattern 2 — filter({ has }) and filter({ hasNot }) to match by descendant
When rows can't be told apart by their own text, filter by a child element they contain (or lack).
test('filters cards by a child element', async ({ page }) => {
await page.goto('https://example.com/products');
const onSale = page
.getByRole('article')
.filter({ has: page.getByRole('img', { name: 'Sale' }) });
await expect(onSale.first()).toBeVisible();
const buyable = page
.getByRole('article')
.filter({ hasNot: page.getByText('Out of stock') });
const target = buyable.filter({ hasText: 'Pro' });
await target.getByRole('button', { name: 'Add to cart' }).click();
});
Pattern 3 — Build resilient, chained locators (role-first)
Prefer accessible roles/labels; chain to scope. Avoid index-based and deep CSS selectors.
test('uses resilient chained locators', async ({ page }) => {
await page.goto('https://example.com/settings');
const billing = page.getByRole('region', { name: 'Billing' });
await billing.getByRole('button', { name: 'Update card' }).click();
const proRow = page.getByTestId('plan-row').filter({ hasText: 'Pro' });
await proRow.getByRole('button', { name: 'Select' }).click();
await expect(page.getByText('Plan updated', { exact: true })).toBeVisible();
});
Pattern 4 — Visibility: assertion (auto-wait) vs isVisible() (instant)
This is the most common mistake. Use the assertion to wait; use isVisible() only for branching on current state.
test('handles visibility correctly', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toBeVisible({ timeout: 10_000 });
const banner = page.getByRole('alert', { name: 'Cookie consent' });
if (await banner.isVisible()) {
await banner.getByRole('button', { name: 'Accept' }).click();
}
});
Pattern 5 — waitFor({ state }) for appearance, detachment, and hiding
Use waitFor when you need to block on a state transition without performing an action.
test('waits for explicit element states', async ({ page }) => {
await page.goto('https://example.com/upload');
const spinner = page.getByRole('progressbar');
await page.getByRole('button', { name: 'Start upload' }).click();
await spinner.waitFor({ state: 'visible' });
await spinner.waitFor({ state: 'hidden', timeout: 30_000 });
await expect(page.getByText('Upload complete')).toBeVisible();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'detached' });
});
Pattern 6 — Iterate and assert over a filtered set
Combine filter with all() / nth / toHaveCount for list assertions.
test('asserts across a filtered list', async ({ page }) => {
await page.goto('https://example.com/inbox');
const unread = page.getByRole('listitem').filter({ has: page.getByTestId('unread-dot') });
await expect(unread).toHaveCount(3);
for (const item of await unread.all()) {
await expect(item.getByRole('heading')).toBeVisible();
}
await unread.first().click();
await expect(page.getByTestId('unread-dot')).toHaveCount(2);
});
Best Practices
- Start every locator from
getByRole/getByLabel/getByText, then narrow with .filter(...). Reserve CSS/XPath for cases the accessibility tree can't express.
- Use
.filter({ has }) / .filter({ hasNot }) to select by descendant when text alone is ambiguous; chain filters for AND logic.
- Assert visibility with
await expect(locator).toBeVisible() so it auto-waits; reserve isVisible() for if branches on optional UI.
- Use
toHaveCount(n) for dynamic lists — it retries — instead of comparing await locator.count().
- Replace
waitForTimeout with locator.waitFor({ state }) or simply acting on the locator (which auto-waits).
- Pass
{ timeout } to the specific assertion that needs longer rather than inflating the global timeout.
- Define a locator once as a
const and reuse it — it re-resolves on each use, so it stays valid across re-renders.
Anti-Patterns
page.locator('.row:nth-child(3) .btn.btn-danger'). Index- and class-based; breaks on reorder or restyle. Filter by role + text instead.
expect(await loc.isVisible()).toBe(true). No waiting — races animations and async renders. Use await expect(loc).toBeVisible().
if ((await loc.count()) === 2) {...}. Snapshot count is a race on dynamic lists. Use toHaveCount.
await page.waitForTimeout(2000) before clicking. Arbitrary and flaky. Locators auto-wait; or use waitFor({ state }).
- Selecting from a list by raw
.nth(4) without filtering — order is data-dependent. Filter to the meaningful item first.
- One mega-CSS selector stringing together five descendant combinators instead of chaining scoped locators.
- Raising the global
timeout to mask one slow screen, slowing every other test's failure feedback.
When to Trigger This Skill
- "My locator matches multiple elements / 'strict mode violation'"
- "How do I select the table row that contains this text?"
- "Use
filter / hasText / has to narrow a locator"
- "Difference between
isVisible() and toBeVisible()"
- "Wait for a spinner to disappear before asserting"
- "My selector is brittle / breaks when the UI changes"
- "Assert how many items are in a dynamic list"
- "Replace
waitForTimeout with a proper wait"