Resource library

QA How-To

Playwright has-text selector: Examples and Best Practices

Apply Playwright has-text selector examples to cards, tables, dialogs, lists, frames, and shadow DOM, with exact, regex, and filter locator alternatives.

24 min read | 3,718 words

TL;DR

Reliable Playwright has-text selector examples start with a narrow candidate, such as article.product:has-text("Wireless Mouse") or tr:has-text("INV-1042"). In new code, a role or test ID locator followed by filter({ hasText }) is often easier to maintain.

Key Takeaways

  • Select a stable component type first, then apply :has-text() to that narrow candidate.
  • Use locator.filter({ hasText }) when a role or test ID already defines the component collection.
  • Find table rows through unique cells, not through a short substring across the whole table.
  • Use has and hasNotText to express descendant and negative conditions without positional selectors.
  • Move exact and regular expression matching to getByText or filter because :has-text() is a flexible substring matcher.
  • Treat frames, localization, responsive duplicates, and hidden content as explicit locator design concerns.
  • Package repeated domain selection in focused page object methods while preserving Locator return values.

Playwright has-text selector examples are most effective when the text identifies a containing component rather than the exact element that a user clicks. A product name can identify a card, an invoice number can identify a row, and a dialog heading can identify one modal among several mounted containers.

The CSS pattern is candidate:has-text("substring"). The candidate must be specific because :has-text() searches descendant text, ignores letter case, normalizes whitespace, and accepts substrings. A bare :has-text() can match html, body, and several nested ancestors.

This example library applies the selector to cards, tables, dialogs, navigation, negative states, iframes, shadow DOM, dynamic content, and page objects. It also shows where locator.filter({ hasText }), getByText, role, or test ID produces a more precise contract.

TL;DR

const product = page.locator(
  'article.product:has-text("Wireless Mouse")',
);

await product.getByRole('button', { name: 'Add to cart' }).click();

await expect(page.getByRole('status')).toHaveText(
  'Wireless Mouse added to cart',
);
Pattern Good first choice
CSS card containing text article.product:has-text("Mouse")
Role-based collection containing text getByRole('article').filter({ hasText: 'Mouse' })
Test ID collection containing text getByTestId('product-card').filter({ hasText: 'Mouse' })
Exact text-bearing element getByText('Wireless Mouse', { exact: true })
Candidate with a named descendant cards.filter({ has: page.getByRole('heading', { name }) })
Candidate excluding text cards.filter({ hasNotText: 'Out of stock' })
Controlled text variation cards.filter({ hasText: /^Build \d+ Passed$/ })

Do not choose syntax in isolation. First decide what element owns the requirement, then use its most stable observable identity.

1. Playwright Has-Text Selector Examples: Choose the Candidate First

A :has-text() selector has two decisions:

  1. Which elements are candidates?
  2. Which substring must occur in each candidate's combined text?

In this selector, article.product is the candidate and Wireless Mouse is the substring:

page.locator(
  'article.product:has-text("Wireless Mouse")',
);

The matching article may contain the words in a heading, paragraph, button, or nested badge. That is why the pattern works well for components.

A weak candidate causes broad matches:

page.locator(':has-text("Wireless Mouse")');
page.locator('div:has-text("Wireless Mouse")');

The first can include page-level ancestors. The second can include several wrapper div elements around the product. A stable semantic element, component class, or test attribute is safer:

page.locator('article.product:has-text("Wireless Mouse")');
page.locator('[data-testid="product-card"]:has-text("Wireless Mouse")');

Before using CSS, ask whether a locator collection already expresses the component:

page
  .getByTestId('product-card')
  .filter({ hasText: 'Wireless Mouse' });

Both forms are current. The filter form separates component identity from the text condition and supports RegExp. The CSS form is compact when the component has a clear stable selector.

The examples in this guide retain strictness. A click must resolve to one element. Count assertions can intentionally evaluate several matches, but positional selectors should not conceal unexpected duplicates.

2. Example: Select a Product Card and Act Inside It

Product grids commonly repeat button names. The product title identifies the card, and the button role identifies the action within it.

import { test, expect } from '@playwright/test';

test('adds the Wireless Mouse card to the cart', async ({ page }) => {
  await page.goto('/products');

  const product = page.locator(
    'article.product:has-text("Wireless Mouse")',
  );

  await expect(product).toHaveCount(1);
  await expect(product).toContainText('$49.00');

  await product.getByRole('button', { name: 'Add to cart' }).click();

  await expect(page.getByRole('status')).toHaveText(
    'Wireless Mouse added to cart',
  );
});

The code uses CSS only to identify the container. It does not depend on a button class or nested div structure.

If a product description can mention another product, filtering by all card text can create a false match. Prefer a named heading condition:

const products = page.getByTestId('product-card');

const product = products.filter({
  has: page.getByRole('heading', {
    name: 'Wireless Mouse',
    exact: true,
  }),
});

The descendant locator is evaluated relative to each product candidate. This says the card must contain the exact named heading, not merely mention the phrase anywhere.

A data-testid can be even more direct when product SKU is the stable domain identity:

const product = page.getByTestId('product-SKU-MS-200');

Text coverage is useful when visible identity matters. Test ID coverage is useful when translated or editable product names should not break every workflow. Playwright getByTestId examples explains that tradeoff.

Also assert the cart line or item count when duplicate additions are a risk. A toast proves feedback appeared, while cart state proves the intended product was actually recorded.

3. Example: Find a Table Row by a Unique Cell

An invoice table needs row-level scoping before clicking Download, Refund, or View. A compact CSS example is:

const invoice = page.locator(
  'tr:has-text("INV-1042")',
);

await invoice.getByRole('button', { name: 'Download' }).click();

This is acceptable when INV-1042 is unique and cannot occur in another column. A semantic version targets an exact cell:

const invoice = page
  .getByRole('row')
  .filter({
    has: page.getByRole('cell', {
      name: 'INV-1042',
      exact: true,
    }),
  });

await expect(invoice.getByRole('cell')).toHaveText([
  'INV-1042',
  'Asha Patel',
  '$120.00',
  'Paid',
  'Download',
]);

await invoice.getByRole('button', { name: 'Download' }).click();

The cell condition is stronger than a substring across all row text. It avoids matching a notes cell that says "Adjustment for INV-1042."

Do not locate the entire table with table:has-text("INV-1042") and then call getByRole('button', { name: 'Download' }). If several rows have Download buttons, strictness fails because the table scope remains too broad.

For multiple expected paid rows, query the collection and assert its count:

const paidRows = page.locator('tbody tr:has-text("Paid")');
await expect(paidRows).toHaveCount(4);

A count is valid for a collection. To act on one row, add a unique identifier. Avoid nth() unless row order itself is the feature, such as selecting the highest-ranked search result.

If virtualized rows are used, locate and assert only rendered records after applying the application's search or filter. Do not assume offscreen, unmounted data participates in DOM text matching.

4. Example: Scope a Dialog or Settings Panel

Applications sometimes keep several dialogs mounted while only one is active. Visible heading text can identify the intended dialog, but first prefer accessible dialog names when markup supports them:

const dialog = page.getByRole('dialog', {
  name: 'Notification settings',
});

await dialog.getByRole('checkbox', { name: 'Build failures' }).check();
await dialog.getByRole('button', { name: 'Save' }).click();

If a legacy dialog has no accessible name but has a stable class and descendant heading, a qualified text selector is a practical fallback:

const dialog = page.locator(
  'div.settings-dialog:has-text("Notification settings")',
);

await dialog.getByRole('button', { name: 'Save' }).click();

This fallback can expose an accessibility improvement opportunity. Connect the heading through aria-labelledby so getByRole can name the dialog.

When hidden and visible copies both match, diagnose the DOM before appending :visible:

const dialogs = page.locator(
  'div.settings-dialog:has-text("Notification settings")',
);

console.log(await dialogs.count());

If the application intentionally mounts transitions, visibility may be part of the requirement:

const activeDialog = page.locator(
  'div.settings-dialog:visible' +
  ':has-text("Notification settings")',
);

Use this only after confirming why duplicates exist. An old dialog that should have been removed can be a product defect rather than a locator inconvenience.

After Save, assert the dialog closes or displays a success state and verify the setting persists when that behavior matters. The action completing does not prove the correct mounted dialog handled it.

5. Example: Combine :has-text() With :has() and Locator has

Text often identifies content while a descendant identifies capability. The requirement might be "the Team plan that has an enabled Choose button."

CSS form:

const selectableTeam = page.locator(
  'article.plan:has-text("Team")' +
  ':has(button:not([disabled]))',
);

await selectableTeam
  .getByRole('button', { name: 'Choose plan' })
  .click();

:has-text() is Playwright's text extension. :has() is a relational CSS pseudo-class that keeps an article if the descendant selector matches.

Locator form:

const plans = page.getByTestId('plan-card');
const choose = page.getByRole('button', { name: 'Choose plan' });

const selectableTeam = plans
  .filter({ hasText: 'Team' })
  .filter({ has: choose });

await selectableTeam
  .getByRole('button', { name: 'Choose plan' })
  .click();

The inner choose locator is evaluated within each plan and must belong to the same frame. This composition is often easier to extend and review than a large CSS string.

An enabled assertion can be clearer than encoding enabled state into selection:

const team = plans.filter({ hasText: 'Team' });
const chooseTeam = team.getByRole('button', { name: 'Choose plan' });

await expect(chooseTeam).toBeEnabled();
await chooseTeam.click();

This version distinguishes "Team card not found" from "Team plan cannot be chosen." Prefer separate assertions when those are different product risks.

Choose conditions that describe different facts. Repeating the same label in :has-text() and a child text locator adds noise rather than confidence. A useful combination pairs identity, such as the Team plan, with capability, such as an enabled purchase control or a required badge.

6. Example: Exclude Text With hasNotText

CSS :has-text() has no direct not-text argument. You can use CSS :not() around the pseudo-class, but locator.filter({ hasNotText }) is usually clearer:

const available = page
  .getByTestId('product-card')
  .filter({ hasNotText: 'Out of stock' });

await expect(available).toHaveCount(6);

You can combine positive and negative text:

const availableKeyboards = page
  .getByTestId('product-card')
  .filter({ hasText: 'Keyboard' })
  .filter({ hasNotText: 'Out of stock' });

Before using absence as proof, inspect the product contract. Not displaying "Out of stock" does not guarantee that Add to cart is enabled. A positive status or enabled control can be stronger:

const available = page
  .getByTestId('product-card')
  .filter({
    has: page.getByRole('button', { name: 'Add to cart' }),
  });

await expect(
  available.getByRole('button', { name: 'Add to cart' }),
).toBeEnabled();

hasNotText accepts a string or regular expression. Use a regex for controlled variants:

const healthy = page
  .getByTestId('service-row')
  .filter({ hasNotText: /failed|offline|degraded/i });

Again, an explicit Healthy status may be more credible. Negative filtering is best when the requirement itself is exclusion, such as listing plans that do not contain a deprecated badge.

Negative filters can change identity as content updates. Keep a stable card key and use hasNotText mainly for collections or explicit exclusion requirements. For one service, locate its row by name and assert the positive health status instead of defining it by every error word it lacks.

7. Playwright Has-Text Selector Examples for Exact and Regex Needs

:has-text() is always a case-insensitive substring matcher. Do not try to place JavaScript regex syntax inside its quoted CSS argument.

For exact text-bearing elements, use getByText:

const save = page.getByText('Save', { exact: true });
await save.click();

If a CSS selector is justified, :text-is() provides exact text for the smallest matching element:

const saveButton = page.locator('button:text-is("Save")');
await saveButton.click();

For a container with a controlled dynamic string, use filter hasText with RegExp:

const completedBuild = page
  .getByTestId('build-row')
  .filter({
    hasText: /^Build 1042\s+Completed\s+Duration: \d+ seconds$/i,
  });

A regex over an entire row can become brittle when columns change. Prefer separate conditions when the page has roles:

const build = page
  .getByRole('row')
  .filter({
    has: page.getByRole('cell', {
      name: 'Build 1042',
      exact: true,
    }),
  });

await expect(
  build.getByRole('cell', { name: 'Completed', exact: true }),
).toBeVisible();

This pattern gives better failure granularity.

Choose exact matching when copy is a stable contract. Choose regex when variation is intentional and bounded. Choose a test ID when localized or user-generated copy should not identify the component.

Avoid regex patterns that mirror an entire changing component. They are difficult to review and can backtrack over irrelevant copy. Split identity and state into separate locators or assertions, then use regex only for bounded values such as an approved build number format.

8. Example: Match Navigation and Custom Tooltips Carefully

A navigation landmark should usually be found by role and accessible name:

const primaryNav = page.getByRole('navigation', {
  name: 'Primary',
});

await primaryNav.getByRole('link', { name: 'Resources' }).click();

A CSS text selector can work in legacy markup:

const nav = page.locator(
  'nav.primary:has-text("Resources")',
);

await nav.getByRole('link', { name: 'Resources' }).click();

The role version is more direct because the user acts on a link named Resources. The CSS version is valuable only when nav scope matters and the accessibility name is missing.

Do not confuse visible text with the title attribute. For a compact icon whose stable identifier is title, use getByTitle:

await page.getByTitle('Open resource filters').click();

Playwright getByTitle examples covers exact, regex, and frame patterns for title attributes.

For a custom tooltip rendered after hover:

const help = page.getByRole('button', { name: 'Plan limits help' });

await help.hover();
await expect(page.getByRole('tooltip')).toHaveText(
  'Limits reset at the start of each billing cycle.',
);

Using a container :has-text() to find the tooltip is usually broader than using its role. A native browser title tooltip is not necessarily a DOM tooltip element, so test its title attribute instead.

Selector failures can reveal missing accessibility semantics. When a navigation landmark or tooltip has no useful role and name, improve the product markup instead of permanently compensating with a broad wrapper selector.

9. Example: Work Inside Iframes and Open Shadow Roots

Frames require an explicit FrameLocator:

import { test, expect } from '@playwright/test';

test('chooses a product inside an embedded catalog', async ({ page }) => {
  await page.goto('/partner-catalog');

  const catalog = page.frameLocator('iframe[title="Partner catalog"]');
  const product = catalog.locator(
    'article.product:has-text("Wireless Mouse")',
  );

  await expect(product).toBeVisible();
  await product.getByRole('button', { name: 'Choose' }).click();
});

A page-level :has-text() cannot search the iframe's document. Locate the frame, then build all child locators within it.

Playwright CSS locators pierce open shadow roots. If user-card contains open shadow content, this can match the custom element:

const user = page.locator(
  'user-card:has-text("Asha Patel")',
);

await user.getByRole('button', { name: 'Open profile' }).click();

Closed shadow roots cannot be traversed. Test the public behavior exposed by the component or collaborate with the component team on a stable test contract.

Custom elements can aggregate a large amount of internal text. If Asha Patel appears in history, badges, and menus, qualify the identity through a public heading, role, or test ID rather than depending on all shadow text.

Frames also affect filter has locators. The inner locator must be in the same frame as the outer candidate. Create both from the same FrameLocator when filtering content inside an iframe.

Frame and shadow boundaries also affect debugging. Capture the correct frame in traces and inspect whether a custom element uses an open root before rewriting a working semantic locator as XPath.

10. Example: Synchronize With Dynamic Lists

Locators re-evaluate when assertions and actions run. You do not need a fixed delay before filtering an asynchronously loaded list.

const completed = page
  .getByTestId('job-row')
  .filter({ hasText: 'Completed' });

await expect(completed).toHaveCount(5);

toHaveCount retries until the expected count or assertion timeout. If jobs transition through states, identify a unique job first:

const job = page
  .getByTestId('job-row')
  .filter({ hasText: 'Import customers.csv' });

await expect(job).toContainText('Completed');
await expect(job).not.toContainText('Processing');

This is more stable than repeatedly building a locator whose identity changes from :has-text("Processing") to :has-text("Completed"). The job name remains identity, while status becomes the assertion.

When an action triggers the change:

await job.getByRole('button', { name: 'Retry' }).click();
await expect(job).toContainText('Queued');
await expect(job).toContainText('Completed', { timeout: 30_000 });

Use a timeout based on the documented operation expectation, not an arbitrary wait copied across the suite.

If a duplicate job name is possible, add a run-specific ID or exact cell condition. Dynamic text does not weaken strictness; it makes stable identity more important.

When list order changes during updates, avoid nth-based identity. Use a stable job ID, file name, or dedicated test attribute, then let status assertions retry within that one container.

If the backend exposes a completion event, assert the same user-visible status rather than replacing UI synchronization with an internal-only shortcut.

11. Package Repeated Selection in a Page Object

A page object can hide structural details while returning Locators for retrying assertions.

import { type Locator, type Page } from '@playwright/test';

export class CatalogPage {
  constructor(private readonly page: Page) {}

  productNamed(name: string): Locator {
    return this.page
      .getByTestId('product-card')
      .filter({
        has: this.page.getByRole('heading', {
          name,
          exact: true,
        }),
      });
  }

  addButtonFor(name: string): Locator {
    return this.productNamed(name)
      .getByRole('button', { name: 'Add to cart' });
  }

  async addToCart(name: string): Promise<void> {
    await this.addButtonFor(name).click();
  }
}

The test keeps business meaning visible:

test('adds a mouse to the cart', async ({ page }) => {
  const catalog = new CatalogPage(page);

  await page.goto('/products');
  await catalog.addToCart('Wireless Mouse');

  await expect(page.getByRole('status')).toHaveText(
    'Wireless Mouse added to cart',
  );
});

Avoid a generic locateByText(selector, text) helper. It merely renames Playwright and encourages selector strings to spread through tests.

A domain method can choose filter has instead of :has-text() without forcing every specification to change. Keep return types as Locator so tests retain web-first assertions and traceable actions.

For localized catalogs, accept a product ID for selection and assert translated display names in focused locale tests. Page objects should not pretend editable marketing copy is a permanent technical key.

Keep assertions that define a reusable action close to that action, but let scenario-specific assertions stay in the test. This balance prevents page objects from swallowing every failure while still ensuring that a click helper waits for its essential result.

Name methods after business objects and actions, not after selector engines, so callers remain readable when implementation changes.

12. Review Real Examples With a Stability Checklist

For every text-conditioned locator, ask:

  • Is the candidate a stable card, row, dialog, list item, or custom element?
  • Can an ancestor or nested wrapper also be a candidate?
  • Is the phrase unique in this scope?
  • Is substring and case-insensitive matching intentional?
  • Could a description or hidden label repeat the phrase?
  • Is the text translated, user-generated, or frequently edited?
  • Would a heading, cell, role, title, or test ID be more precise?
  • Does a filter RegExp describe variation better?
  • Are hidden responsive duplicates expected?
  • Is the locator in the correct frame?
  • Does the component use an open or closed shadow root?
  • Will a strict action resolve to one element?
  • Does the final assertion prove business behavior?

Use Inspector and trace when the answer is unclear:

npx playwright test tests/catalog.spec.ts --debug
npx playwright test tests/catalog.spec.ts --trace=on

Inspect all matches before using a positional method:

const cards = page.locator('article:has-text("Team")');

console.log(await cards.count());
console.log(await cards.allTextContents());

If more than one card is legitimate, add a unique feature attribute. If duplicates are unexpected, fix the page state or selector. Fixing Playwright strict mode violations provides the full workflow.

For the matching rules behind every pattern here, see how to use the Playwright has-text selector.

Interview Questions and Answers

Q: Give a reliable :has-text() example for a product card.

I use a narrow candidate such as article.product:has-text("Wireless Mouse"), then locate the button inside by role. I assert the card is unique before the action and verify a cart or status outcome afterward. If descriptions can repeat the name, I filter by an exact heading instead.

Q: How do you find a table row by text without matching the entire table?

I start from rows and filter by a unique cell, preferably an exact invoice or order ID. A tr:has-text("INV-1042") selector is acceptable when the ID is unique across row content. I never keep the table as the final scope when several action buttons repeat.

Q: How do you represent a container that has text and an enabled child?

I can combine CSS :has-text() with :has(button:not([disabled])). In locator code, I often filter a component collection by hasText and then assert the named button is enabled. Separate assertions produce clearer failures when capability matters.

Q: How do you exclude cards containing a status?

I use locator.filter({ hasNotText: 'Out of stock' }) or a RegExp for several excluded states. I confirm that absence really represents availability. An explicit status or enabled Add button may be a stronger positive contract.

Q: What do you use for exact and regex text?

I use getByText with exact true for an exact text-bearing element. For container filtering with controlled variation, I pass RegExp to filter hasText. :has-text() itself remains case-insensitive substring matching.

Q: How do text selectors behave with dynamic content?

Locators re-resolve and web-first assertions retry. I keep identity stable, such as a job name or ID, and assert the changing status inside that container. Fixed waits are unnecessary and hide timing assumptions.

Q: Can :has-text() cross frames or shadow roots?

It cannot cross an iframe from the parent page, so I enter with FrameLocator first. Playwright CSS selectors pierce open shadow roots, but not closed roots. Inner has locators must stay in the same frame as their outer locator.

Q: How do you refactor repeated has-text selectors?

I create a focused page object method that returns a Locator for a named domain object. The method can migrate from CSS to role, heading, or test ID filtering without changing every test. I avoid generic wrappers that only rename page.locator.

Common Mistakes

  • Starting with div or a bare :has-text() instead of a stable component candidate.
  • Using a product substring that also appears in descriptions of other products.
  • Locating a whole table and then clicking one of several repeated buttons.
  • Treating absence of an error phrase as proof of a healthy state.
  • Attempting to put JavaScript regex syntax inside :has-text().
  • Adding :visible without investigating responsive or stale duplicates.
  • Using a dialog text fallback when the dialog already has an accessible name.
  • Confusing title attributes or custom tooltips with ordinary visible text.
  • Keeping dynamic status as locator identity instead of an assertion.
  • Crossing an iframe from a page-level locator.
  • Expecting closed shadow roots to be traversable.
  • Choosing nth() to silence a strict mode failure.
  • Coupling every locale workflow to English marketing copy.
  • Returning booleans from page objects instead of Locators.
  • Verifying only the click and not the resulting business state.

Conclusion

The strongest Playwright has-text selector examples begin with a precise candidate: an article, row, dialog, list item, or stable component attribute. Apply :has-text() only when flexible descendant substring matching is intentional, then locate the action semantically inside the matched container.

Use filter({ hasText }) for role and test ID composition, has for exact descendant structure, hasNotText for real exclusion requirements, and getByText or regex filters for tighter matching. Keep strictness, account for frames and localization, and make the outcome assertion the final authority.

Interview Questions and Answers

How would you locate a product card by its visible name?

I use a stable card candidate and filter it by the exact product heading when possible. A qualified article.product:has-text selector is acceptable when the name cannot appear elsewhere. I then locate the action by role and assert the cart outcome.

What is your preferred table-row locator pattern?

I start with role row and filter by an exact unique cell such as an invoice ID. This avoids matching notes or the entire table. I keep the row as scope for cells and action buttons.

How do you combine text and descendant capability?

CSS can combine :has-text() and :has(). Locator code can chain filter hasText and filter has, or locate the text-identified component and assert its button is enabled. I prefer separate assertions when they distinguish two risks.

When is hasNotText appropriate?

It is appropriate when exclusion is the documented requirement, such as cards without a deprecated badge. I do not assume missing error copy proves success. A positive status or enabled control is stronger when available.

How do you handle dynamic text in a locator?

I keep the object's identity stable through ID, name, or exact cell and assert the changing status inside it. Locators and web-first assertions retry, so fixed waits are unnecessary. Regex is reserved for bounded intentional variation.

What changes for iframe content?

I create all candidate and child locators from the appropriate FrameLocator. A page-level selector cannot cross the frame boundary. Locator has conditions also require the same frame.

How do you make text selectors localization-safe?

I use domain IDs or test IDs for workflows where translated copy is not the requirement. I keep focused locale tests for visible translations and accessibility names. I do not use English marketing copy as a universal technical key.

What would you inspect after a strict mode violation?

I inspect count, all text contents, visibility, frame, and page state in a trace or Inspector. Then I add meaningful domain scope or a unique child condition. I avoid first or nth unless position is explicitly tested.

Frequently Asked Questions

How do I select a card containing text in Playwright?

Use a qualified CSS locator such as article.product:has-text("Wireless Mouse"). In modern locator code, get the card collection by role or test ID and call filter({ hasText: 'Wireless Mouse' }).

How do I find a Playwright table row by cell text?

Start with getByRole('row') and filter by an exact cell locator, or use tr:has-text("unique ID") when the ID cannot appear elsewhere in the row. Keep the resulting row as scope for its action.

How do I select an element that has text and a child element?

Combine :has-text() with CSS :has(), or chain locator filters with hasText and has. The child locator passed to has is evaluated relative to each candidate and must be in the same frame.

How do I exclude text in a Playwright locator?

Use locator.filter({ hasNotText: 'Out of stock' }) with a string or regular expression. Confirm that absence of the phrase is truly the product condition you need.

Can :has-text() match exact text?

No, it performs case-insensitive substring matching. Use getByText with exact true, :text-is() when CSS is required, or filter hasText with an anchored regular expression.

How do I use :has-text() inside an iframe?

Create a FrameLocator for the iframe, then call frameLocator.locator with the qualified :has-text() selector. Page-level locators cannot search text inside a child frame.

Why does my has-text selector match several cards?

The candidate may be broad, the substring may repeat in descriptions, or hidden responsive copies may exist. Inspect all matches and narrow with a unique heading, cell, role, or test ID.

Should a page object return a text match result or a Locator?

Return a Locator so Playwright actions and assertions retain auto-waiting, retryability, and useful traces. Wrap domain selection, not a generic boolean snapshot.

Related Guides