# Playwright Locator filter & Visibility

> Teaches the agent to build resilient Playwright locators with .filter (hasText/has/hasNot), narrow lists, and reason correctly about visibility, waitFor states, and timeouts.

- Skill: `pramoddutta/playwright-locator-filter-visibility` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pramoddutta/playwright-locator-filter-visibility`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pramoddutta/playwright-locator-filter-visibility/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: PramodDutta (https://skillmd.com/u/pramoddutta)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/pramoddutta/playwright-locator-filter-visibility

---


# 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"

