Teaches the agent to build resilient Playwright locators with .filter (hasText/has/hasNot), narrow lists, and reason correctly about visibility, waitFor states, and timeouts.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add PramodDutta/qaskills --skill playwright-locator-filter --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Playwright Locator Filter?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pramoddutta-playwright-locator-filter)More formats (shields.io, HTML) on the badges page.
---
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
1. **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.
2. **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.
3. **`expect(locator).toBeVisible()` auto-waits; `locator.isVisible()` does not.** The assertion polls until the timeout; `isVisible()` returns a boolean *right now* with no waiting.
4. **Never assert on a `count()` snapshot for dynamic lists.** Use `await expect(locator).toHaveCount(n)` which retries; `(await loc.count()) === n` is a race.
5. **Prefer waiting for state over `waitForTimeout`.** `locator.waitFor({ state: 'visible' })` (or just acting on the locator) replaces arbitrary sleeps.
6. **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.
```typescript
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).
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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
1. **Start every locator from `getByRole`/`getByLabel`/`getByText`**, then narrow with `.filter(...)`. Reserve CSS/XPath for cases the accessibility tree can't express.
2. **Use `.filter({ has })` / `.filter({ hasNot })`** to select by descendant when text alone is ambiguous; chain filters for AND logic.
3. **Assert visibility with `await expect(locator).toBeVisible()`** so it auto-waits; reserve `isVisible()` for `if` branches on optional UI.
4. **Use `toHaveCount(n)`** for dynamic lists — it retries — instead of comparing `await locator.count()`.
5. **Replace `waitForTimeout` with `locator.waitFor({ state })`** or simply acting on the locator (which auto-waits).
6. **Pass `{ timeout }` to the specific assertion** that needs longer rather than inflating the global timeout.
7. **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
1. **`page.locator('.row:nth-child(3) .btn.btn-danger')`.** Index- and class-based; breaks on reorder or restyle. Filter by role + text instead.
2. **`expect(await loc.isVisible()).toBe(true)`.** No waiting — races animations and async renders. Use `await expect(loc).toBeVisible()`.
3. **`if ((await loc.count()) === 2) {...}`.** Snapshot count is a race on dynamic lists. Use `toHaveCount`.
4. **`await page.waitForTimeout(2000)` before clicking.** Arbitrary and flaky. Locators auto-wait; or use `waitFor({ state })`.
5. **Selecting from a list by raw `.nth(4)`** without filtering — order is data-dependent. Filter to the meaningful item first.
6. **One mega-CSS selector** stringing together five descendant combinators instead of chaining scoped locators.
7. **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"
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!