by thetestingacademy
Teaches the agent to build resilient Playwright locators with .filter (hasText/has/hasNot), narrow lists, and reason correctly about visibility, waitFor states, and timeouts.
npx @qaskills/cli add playwright-locator-filterAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.
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.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.count() snapshot for dynamic lists. Use await expect(locator).toHaveCount(n) which retries; (await loc.count()) === n is a race.waitForTimeout. locator.waitFor({ state: 'visible' }) (or just acting on the locator) replaces arbitrary sleeps.{ timeout } to the specific expect/action that genuinely needs longer.filter({ hasText }) to pick one row out of manyThe 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);
});
filter({ has }) and filter({ hasNot }) to match by descendantWhen 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();
});
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();
});
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);
});
waitFor({ state }) for appearance, detachment, and hidingUse 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' });
});
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);
});
getByRole/getByLabel/getByText, then narrow with .filter(...). Reserve CSS/XPath for cases the accessibility tree can't express..filter({ has }) / .filter({ hasNot }) to select by descendant when text alone is ambiguous; chain filters for AND logic.await expect(locator).toBeVisible() so it auto-waits; reserve isVisible() for if branches on optional UI.toHaveCount(n) for dynamic lists — it retries — instead of comparing await locator.count().waitForTimeout with locator.waitFor({ state }) or simply acting on the locator (which auto-waits).{ timeout } to the specific assertion that needs longer rather than inflating the global timeout.const and reuse it — it re-resolves on each use, so it stays valid across re-renders.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 })..nth(4) without filtering — order is data-dependent. Filter to the meaningful item first.timeout to mask one slow screen, slowing every other test's failure feedback.filter / hasText / has to narrow a locator"isVisible() and toBeVisible()"waitForTimeout with a proper wait"- name: Install QA Skills
run: npx @qaskills/cli add playwright-locator-filter12 of 29 agents supported