skip to content

Query Builders

The built-in ways to name an element: accessible role and name, visible text and attributes, a raw selector string, or a document inside an iframe. Each has its own matching rule.

on this pageshow

explore

questions

20

In Playwright, how do you click a button that renders inside an iframe?

level: juniorimportance: must knowfreq 74%

answer

  1. A second document inside the page
  2. Enter once, then keep chaining
  3. Nothing global changes, no switch back
  4. One frame locator per nesting level

basics

~20 s

Call page.frameLocator with a selector for the iframe, then chain the element locator onto it. Everything after the frame locator resolves inside that iframe, and locators built from page are unaffected, so there is nothing to switch back.

solid answer

~40 s

`page.frameLocator('#composer')` returns a `FrameLocator` -- a view into that iframe. Chain any locator onto it, such as `.getByRole('button', { name: 'Comment' })`, and the search runs inside the iframe's document instead of the top document. The chain is lazy: nothing resolves until you act or assert, so Playwright waits for the iframe element to appear, for its document to be ready, and for the target inside it, which means a lazily injected embed needs no explicit wait. Entering a frame changes nothing globally -- `page.getByRole(...)` still searches the main frame on the next line, so there is no switch back. For nested iframes, chain one frame locator per level. If you already hold a locator for the iframe element, `locator.contentFrame()` gives you the same `FrameLocator`.

code

typescript · 12 lines
typescript
import { test, expect } from '@playwright/test';

test('adds a comment from the rich-text composer', async ({ page }) => {
  await page.goto('/issues/PROJ-142');

  const composer = page.frameLocator('iframe[title="Comment composer"]');
  await composer.getByRole('textbox', { name: 'Comment body' }).fill('Cannot reproduce.');
  await composer.getByRole('button', { name: 'Comment' }).click();

  // Straight back outside the iframe - no switch needed.
  await expect(page.getByText('Comment added')).toBeVisible();
});

go deeper

for a junior

Know that elements inside an iframe are invisible to page-level locators, and that page.frameLocator plus a chained locator is the way in. Say the chain reads outermost first.

for a middle

Explain that the frame locator is lazy and re-resolved on every use, so one action waits for the iframe element, its document and the target element without any explicit wait step.

for a senior

Show where the frame boundary belongs in a page object: expose the composer as one reusable frame locator instead of repeating the iframe selector across twenty tests.

for a principal

Own the boundary argument. A third-party embed you do not control deserves a single seam in the suite so a vendor markup change is one edit, not a day of triage.

## The problem an iframe creates An `<iframe>` embeds a whole second document inside the page. On an issue tracker that is typically the rich-text comment composer, a metrics panel on the issue detail view, or a partner board embed. The DOM inside that document is not reachable from the top document, so a locator built from `page` -- `page.getByRole('button', { name: 'Comment' })` -- will never see it, however long it waits. Playwright's answer is the **frame locator**. `page.frameLocator(selector)` takes a selector that matches the **iframe element** in the current document and returns a `FrameLocator`: a view into the document that iframe hosts. Anything chained onto it resolves inside that document. ```ts const composer = page.frameLocator('iframe[title="Comment composer"]'); await composer.getByRole('textbox', { name: 'Comment body' }).fill('Cannot reproduce on staging.'); await composer.getByRole('button', { name: 'Comment' }).click(); ``` ## Chaining is the whole API - The chain reads **outermost first**: the page, then the iframe, then the element. - Everything after the frame locator -- `getByRole`, `getByText`, `locator`, `filter`, `nth` -- searches inside the iframe rather than the page. - A `FrameLocator` is **not a promise**. Nothing is queried when you build it, so you never `await` the frame locator itself; you await the action or assertion at the end of the chain. - It is reusable: store it once and hang many locators off it. Every use re-resolves the iframe from the selector. - Entering a frame changes **nothing globally**. `page.getByRole(...)` still searches the main frame on the very next line. ## What the chain waits for Because the chain is lazy, the waiting happens at the end and covers the whole path: 1. the iframe element matching your selector appears in the current document, 2. that iframe has a document to search, 3. the element you chained appears inside it and passes the action's actionability checks. A composer iframe injected a second after the issue page paints therefore needs no explicit wait step -- `composer.getByRole('button').click()` waits through all three under the one action timeout. The same re-resolution is why a chain survives the embed being torn down and rebuilt while the call is in flight: the selector is evaluated again, not a captured handle. ## Nested iframes There is one frame locator **per level**; you do not pass a compound selector to a single call. ```ts await page .frameLocator('#board-embed') .frameLocator('iframe[name="card-preview"]') .getByRole('link', { name: 'PROJ-142' }) .click(); ``` ## Three ways to name the frame | Expression | Starts from | Reach for it when | |---|---|---| | `page.frameLocator('#composer')` | a selector for the iframe element | the normal case | | `page.locator('#composer').contentFrame()` | a `Locator` you already hold | you already built or narrowed the iframe locator | | `page.frameLocator()` with no selector (Playwright 1.63) | any frame in the subtree | you would rather not name the iframe at all | ## Why there is no switch back Playwright keeps no "current frame" state on the page. Which document a locator searches is a property of **that locator chain**, not of the session, and that has consequences worth stating in an interview: - Two chains can address two different frames in adjacent lines with no ordering rules between them. - A helper that accepts a `FrameLocator` composes exactly like one that accepts a `Locator`, so page objects can hand out a frame-scoped root. - A failed action cannot leave the test "stuck inside" a frame, so there is no cleanup step and no leaked state between tests. ## Where it goes wrong - Pointing a frame locator at something that is not an iframe fails with an error saying an `<iframe>` was expected, printing the element it found instead -- usually the wrapper `<div>` around the embed. - A selector matching several iframes makes the frame locator ambiguous; name the specific embed, or index the iframe locator before converting it. - Assuming a page-level locator can still see inside after you entered a frame. It never could -- nothing was switched, so nothing was restored.

  • How would you reach a link inside an iframe that is itself inside another iframe?
    Chain one frame locator per level: `page.frameLocator('#board-embed').frameLocator('iframe[name="card-preview"]').getByRole('link', { name: 'PROJ-142' })`. There is no compound selector form; each call steps down exactly one document, and the whole chain still resolves lazily at the action.
  • What happens if the selector you pass to page.frameLocator matches a div rather than an iframe?
    The action fails on timeout with an error saying an `<iframe>` was expected, and the message prints the element that was actually matched -- commonly the wrapper `div` around the embed. The fix is to target the iframe element itself, not its container.
  • Does the test need to wait for the iframe to load before creating the frame locator?
    No. Building a `FrameLocator` queries nothing. The wait happens at the action or assertion and covers the iframe appearing, its document becoming available, and the target element inside it, all under the one action timeout.

It is a postal address, not a doorway you walk through: each locator carries the whole route, so no one has to walk back out.

saying these in an interview costs you the question

  • Saying you must switch back out of the frame after acting
  • Adding a manual wait for the iframe before building the frame locator
  • Expecting page.getByRole to find elements inside an iframe
  • Awaiting the frame locator itself as if it were a promise
  • Passing one compound selector instead of chaining per nesting level
open as a page

In Playwright, what does page.getByRole('heading', { name: 'Issue 42' }) actually match?

level: juniorimportance: must knowfreq 86%

basics

~20 s

page.getByRole('heading', { name: 'Issue 42' }) matches an element whose ARIA role is heading, implicit for h1 to h6 or set by a role attribute, and whose computed accessible name contains the text Issue 42.

open as a page

In Playwright, how does page.locator() decide whether an unprefixed selector string is CSS or XPath?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Playwright reads an unprefixed string as XPath when it starts with // or .. , as a text selector when the whole string is quoted, and as CSS otherwise. Write css= or xpath= to remove the guess.

open as a page

In Playwright, what is the difference between page.getByText('Open') and page.getByText('Open', { exact: true })?

level: juniorimportance: must knowfreq 78%

basics

~10 s

By default page.getByText matches a case-insensitive substring of an element's whitespace-normalized text, so Open also matches Reopened. With exact set to true the whole string must match, case-sensitively. Whitespace is normalized either way.

open as a page

In Playwright, why does page.getByRole('button', { name: 'Save' }) also match a button named 'Save and close'?

level: middleimportance: must knowfreq 74%

basics

~10 s

The name option is a case-insensitive substring test against the accessible name by default, so any name containing Save matches. Pass exact: true for a case-sensitive whole-string match, or a RegExp for full control.

open as a page

Why does Playwright's page.getByTestId offer no exact option when page.getByText and page.getByLabel both do?

level: middleimportance: must knowfreq 68%

basics

~10 s

Because a test id match is always exact. page.getByTestId compares the whole attribute value case-sensitively, so there is nothing for an option to relax. It reads data-testid unless the testIdAttribute option names another attribute.

open as a page

What do Playwright's locator.contentFrame() and frameLocator.owner() do?

level: middleimportance: should knowfreq 38%

basics

~20 s

They convert between the two views of an iframe. locator.contentFrame() turns a Locator for the iframe element into a FrameLocator for the document inside it, and frameLocator.owner() turns a FrameLocator back into a Locator for the iframe element.

open as a page

In Playwright's page.getByRole, when can you use the checked, pressed, expanded, selected and level options?

level: middleimportance: should knowfreq 51%

basics

~20 s

Only on roles that support the state. Playwright reads the value from an aria attribute or the native control and rejects an unsupported pairing, such as pressed on anything but button, with an error naming the allowed roles.

open as a page

In a Playwright CSS selector, what is the difference between :has() and :has-text()?

level: middleimportance: should knowfreq 52%

basics

~20 s

The :has() pseudo-class takes a selector and keeps elements that contain a match for it. The :has-text() pseudo-class takes a string and keeps elements whose subtree contains that text, matched case-insensitively as a trimmed substring.

open as a page

In Playwright, how do you restrict page.locator('button') to only the visible buttons?

level: middleimportance: should knowfreq 44%

basics

~20 s

Call locator.visible(), added in Playwright 1.63, which returns a locator matching only the visible elements. The older :visible CSS pseudo-class does the same inside a selector string. Visible means a non-empty bounding box and no visibility:hidden.

open as a page

In a Playwright test, when an issue title sits inside a card, which elements does page.getByText('Fix login redirect') match?

level: middleimportance: should knowfreq 55%

basics

~20 s

Only the innermost element whose own text matches. Playwright drops any element that has a child element also matching, so the card and its wrapper are excluded and the span holding the title is returned.

open as a page

A Playwright test stores page.frame('composer') and later fails with a detached-frame error after the issue view re-renders. Why, and what should it use instead?

level: seniorimportance: should knowfreq 34%

basics

~20 s

A Frame object is a reference to one live frame. When the issue view removes and re-creates the iframe, that frame is detached and every call through it fails. A frame locator re-resolves its selector on each use instead.

open as a page

Why does page.getByRole('button', { name: 'Delete issue' }) find nothing in Playwright when that button is in the DOM?

level: seniorimportance: should knowfreq 57%

basics

~10 s

Most often the element is hidden for accessibility purposes. Role queries skip anything with aria-hidden, display none, visibility hidden or the hidden attribute, on itself or an ancestor, unless includeHidden is true.

open as a page

In Playwright, why can page.getByRole reach no element for a clickable div in an issue board's row menu?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A div exposes no ARIA role, so no role query can select it. Playwright derives roles from an explicit role attribute or an implicit tag mapping, and div, span and an anchor without href have none.

open as a page

A Playwright locator using css= finds an issue tracker's submit button, but the equivalent xpath= matches nothing -- why?

level: seniorimportance: should knowfreq 36%

basics

~20 s

The button sits inside an open shadow root. Playwright's CSS engine pierces open shadow roots, so css= reaches it; the XPath engine does not cross a shadow boundary, so xpath= only ever finds the host element.

open as a page

An issue card in a Playwright suite reads '3 comments' with a count that changes between runs — how do you locate it by text?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Pass a RegExp instead of a string: page.getByText with a pattern such as one matching digits followed by the word comments. The exact option is ignored for a RegExp, and the pattern is case-sensitive unless you add the i flag.

open as a page

A Playwright test's page.getByLabel('Assignee') finds nothing although the issue form clearly renders an Assignee label — why?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Because page.getByLabel searches controls, not label elements. It matches an element through aria-labelledby, aria-label, or a real HTML label association on a form control. A styled div next to a label element has none of those, so nothing matches.

open as a page

What does the >> token do inside a Playwright selector string like css=article >> text=Reopen?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

The >> token chains selector parts: each part is queried relative to the previous part's result, like nested querySelector calls. Prefixing a part with an asterisk captures that intermediate element instead of the last one.

open as a page

When is registering a custom Playwright selector engine with selectors.register worth its maintenance cost?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Rarely. A custom engine earns its place only when a whole class of elements shares an addressing scheme the built-in engines cannot express and the application guarantees. Otherwise it adds a private query language every test author must learn.

open as a page