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
Locatoris 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. Useawait expect(locator).toHaveCount(n)which retries;(await loc.count()) === nis 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 specificexpect/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');
// All rows share the same role; narrow to the one mentioning the order.
const row = page.getByRole('row').filter({ hasText: 'ORD-1042' });
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Cancel' }).click();
// hasText accepts a RegExp for partial / case-insensitive matching.
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');
// Only cards that CONTAIN a "Sale" badge.
const onSale = page
.getByRole('article')
.filter({ has: page.getByRole('img', { name: 'Sale' }) });
await expect(onSale.first()).toBeVisible();
// Only cards that do NOT contain an "Out of stock" label.
const buyable = page
.getByRole('article')
.filter({ hasNot: page.getByText('Out of stock') });
// Chain filters: buyable AND mentioning "Pro".
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');
// Scope into a section, then target by role within it.
const billing = page.getByRole('region', { name: 'Billing' });
await billing.getByRole('button', { name: 'Update card' }).click();
// Disambiguate same-named buttons by their surrounding text.
const proRow = page.getByTestId('plan-row').filter({ hasText: 'Pro' });
await proRow.getByRole('button', { name: 'Select' }).click();
// getByText with { exact: true } when substring matches are too greedy.
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');
// CORRECT: auto-waits up to the timeout for the toast to appear.
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toBeVisible({ timeout: 10_000 });
// CORRECT use of isVisible(): branch on whether an optional banner exists.
const banner = page.getByRole('alert', { name: 'Cookie consent' });
if (await banner.isVisible()) {
await banner.getByRole('button', { name: 'Accept' }).click();
}
// WRONG (do not do this): isVisible() does not wait, so this races a fade-in.
// expect(await toast.isVisible()).toBe(true);
});
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' });
// Block until the spinner is gone before asserting success.
await spinner.waitFor({ state: 'hidden', timeout: 30_000 });
await expect(page.getByText('Upload complete')).toBeVisible();
// 'attached' / 'detached' for elements added/removed from the DOM entirely.
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') });
// Retrying count assertion — waits for the list to settle.
await expect(unread).toHaveCount(3);
// Per-item assertions.
for (const item of await unread.all()) {
await expect(item.getByRole('heading')).toBeVisible();
}
// Act on the first/last match of the filtered set.
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; reserveisVisible()forifbranches on optional UI. - Use
toHaveCount(n)for dynamic lists — it retries — instead of comparingawait locator.count(). - Replace
waitForTimeoutwithlocator.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
constand 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. Useawait expect(loc).toBeVisible().if ((await loc.count()) === 2) {...}. Snapshot count is a race on dynamic lists. UsetoHaveCount.await page.waitForTimeout(2000)before clicking. Arbitrary and flaky. Locators auto-wait; or usewaitFor({ 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
timeoutto 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/hasto narrow a locator" - "Difference between
isVisible()andtoBeVisible()" - "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
waitForTimeoutwith a proper wait"