Playwright Locators, Actions & Auto-Waiting

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Locators and actions are the two APIs a Playwright test touches on almost every line. A locator describes how to find an element at any moment; an action such as click() or fill() resolves that locator, waits until the element is ready, and only then acts. Together they replace explicit waits in the vast majority of tests. This page covers every built-in locator, the ways to narrow and combine them, the checks Playwright performs before each action (auto-waiting), the input actions, and the page-level features tests need around them: frames, dialogs, downloads, popups, events, evaluating JavaScript, handles and navigations.

The baseline is Playwright 1.64.1 for Node.js (the current release on 25 October 2026); the Python, Java and .NET bindings expose the same locator and action API and were verified at 1.63 and later (see Playwright with Python, Java & .NET). Every example on this page is real test code that was run against the Bookshelf demo application on 25 October 2026, in Chromium and WebKit with Playwright 1.64.1, unless the text says otherwise. The Bookshelf routes, data-testid conventions and credentials are described on that page; passwords are read from BOOKSHELF_READER_PASSWORD and never written in a test. Bookshelf has no iframes, dialogs, downloads or popups, so the examples for those features are served by the test itself: a small serveLab() helper registers a page.route() that answers /lab/<name> with an HTML document (and page.setContent() is used for small snippets), which gives the page a real origin without changing the application. The helper is shown once in The examples on this page.

The model: locators, actionability, retries

A Locator is not an element. It is a recipe (a selector plus the frame it lives in) that Playwright evaluates every time you use it. This has three consequences that the rest of the page builds on:

  • Always up to date. If the page re-renders between two actions, the second action finds the new element. A locator never goes stale, which is the key difference from ElementHandle (see Evaluating JavaScript and handles).

  • Lazy. Creating a locator does not query the page and does not fail when nothing matches. Failure happens when you act on it, and then only after the timeout.

  • Strict. Acting on a locator that matches more than one element is an error (see Strictness and picking by position), while counting or asserting on several elements is fine.

When you call an action, Playwright runs this loop until it succeeds or the action timeout expires: resolve the locator, check the element against the list of actionability checks for that action, scroll it into view if needed, perform the action, and retry from the top if the element detached or a check failed. Web-first assertions such as expect(locator).toBeVisible() use the same retry model; they are covered in Playwright Assertions.

All methods that create a locator exist on Page, Locator, Frame and FrameLocator, so you chain them to narrow the search from the top of the page down to one element.

Built-in locators

Playwright recommends choosing locators in the order a user perceives the page: by role first, then by label, text and the other user-facing attributes, and by test id when there is no accessible handle. CSS and XPath are the last resort.

Locator Finds Use it for

page.getByRole(role, options)

Elements by ARIA role (explicit or implicit) plus optional accessible name and state.

Buttons, links, headings, inputs, checkboxes, tables: anything interactive. The default choice.

page.getByText(text, { exact })

Elements by the text they contain.

Non-interactive content: paragraphs, spans, list items, messages.

page.getByLabel(text, { exact })

Form controls by the text of their associated <label>, aria-labelledby or aria-label.

Form fields.

page.getByPlaceholder(text, { exact })

Inputs by the placeholder attribute.

Inputs that have no label.

page.getByAltText(text, { exact })

Elements (usually images and area elements) by their alt attribute.

Images and image buttons.

page.getByTitle(text, { exact })

Elements by the title attribute.

Tooltips and icons with a title.

page.getByTestId(id)

Elements by the test id attribute (data-testid by default).

An explicit testing contract for elements with no accessible name.

getByRole

getByRole() reflects how assistive technology sees the page. It uses the W3C rules for implicit roles (a <button> is a button, an <h3> is a heading of level 3), ARIA attributes and the accessible-name computation, so the same locator also gives early feedback on accessibility problems (it does not replace an audit; see Accessibility Testing). Pass the accessible name whenever several elements share a role.

Option Type Meaning

name

string or RegExp

Accessible name. A string matches case-insensitively as a substring unless exact is set; a RegExp matches as written.

exact

boolean (1.28)

Match name and description case-sensitively and as a whole string. Ignored for a RegExp. Whitespace is still trimmed.

description

string or RegExp (1.60)

Accessible description (aria-describedby and similar), matched like name.

level

number

Heading level, or the level of a listitem, row or treeitem.

checked

boolean

aria-checked or the native checked state of checkboxes and radio buttons.

disabled

boolean

aria-disabled or disabled. Unlike other attributes it is inherited down the DOM tree.

expanded

boolean

aria-expanded.

pressed

boolean

aria-pressed for toggle buttons.

selected

boolean

aria-selected (tabs, options, rows).

includeHidden

boolean

By default elements hidden from the accessibility tree (hidden, display: none, aria-hidden) are skipped; set this to match them too.

The role argument accepts the ARIA roles Playwright supports (for example button, checkbox, combobox, dialog, heading, link, listitem, navigation, radio, row, searchbox, tab, textbox); the complete list is in the API reference linked under References.

test('getByRole with name, level and exact', async ({ page }) => {
  await expect(page.getByRole('heading', { name: 'Catalogue', level: 1 })).toBeVisible();
  // The name is a case-insensitive substring unless exact is set. The link's name
  // comes from its content: the cover image alt text and the heading.
  await expect(page.getByRole('link', { name: 'quiet compiler' })).toHaveCount(1);
  await expect(page.getByRole('link', { name: 'The Quiet Compiler', exact: true })).toHaveCount(0);
  await expect(page.getByRole('searchbox', { name: 'Search books' })).toBeVisible();
  await expect(page.getByRole('navigation', { name: 'Pagination' })).toBeVisible();
  // Regular expressions work for the name too.
  await expect(page.getByRole('heading', { name: /^Small Systems/, level: 3 })).toHaveCount(1);
});
test('getByRole state options', async ({ page }) => {
  await page.goto('/signin');
  // The sign-in error is rendered with the hidden attribute until a failure happens.
  await expect(page.getByRole('alert')).toHaveCount(0);
  await expect(page.getByRole('alert', { includeHidden: true })).toHaveCount(1);
  await expect(page.getByRole('button', { name: 'Sign in', disabled: false })).toBeEnabled();
});

Two details in that test are worth remembering. The accessible name of a link is built from its content, so the Bookshelf link that wraps a cover image and a heading is found by the substring quiet compiler (the test expects exactly one such link on the catalogue page). And the sign-in error paragraph is role="alert" but carries the hidden attribute until a failure happens, so it only matches with includeHidden: true.

getByText, getByLabel, getByAltText, getByTestId

getByText() matches by the text content: a string is a case-insensitive substring match, exact: true makes it case-sensitive and whole-string, and a RegExp matches as written. Whitespace is always normalised (runs collapse to one space, line breaks become spaces, leading and trailing whitespace is ignored), even with exact. Prefer it for non-interactive elements and use roles for buttons and links. getByLabel() finds the control associated with a label (including aria-label and aria-labelledby), and getByAltText() and getByTitle() match the attributes of the same name. All of them accept exact.

test('getByText, getByLabel, getByAltText, getByTestId', async ({ page }) => {
  await expect(page.getByText('24 books found')).toBeVisible();
  await expect(page.getByText(/^\d+ books found$/)).toBeVisible();
  await expect(page.getByText('Mara Velde', { exact: true })).toHaveCount(1);
  await expect(page.getByLabel('Search books')).toBeVisible();
  await expect(page.getByAltText('Cover of Salt and Signal')).toBeVisible();
  await expect(page.getByAltText('Cover of', { exact: false })).toHaveCount(6);
  await expect(page.getByTestId('book-card')).toHaveCount(6);
  await expect(page.getByTestId(/^page-/)).toHaveCount(3);
  // aria-label is an accessible name, so getByLabel finds it as well.
  await expect(page.getByLabel('Toggle theme')).toHaveText('Theme');
});

getByPlaceholder() and getByTitle() are the two locators Bookshelf’s markup does not exercise, so the next test uses a small document built with page.setContent().

test('getByPlaceholder, getByTitle and a custom test id attribute on a lab page', async ({ page }) => {
  await page.setContent(`
    <input type="email" placeholder="name@example.com">
    <span title="Issues count">25 issues</span>
    <button data-pw="directions">Itineraire</button>
  `);
  await page.getByPlaceholder('name@example.com').fill('reader@example.com');
  await expect(page.getByPlaceholder('name@example.com')).toHaveValue('reader@example.com');
  await expect(page.getByTitle('Issues count')).toHaveText('25 issues');

  // The test id attribute is a per-project setting; it can also be changed at runtime.
  const { selectors } = await import('@playwright/test');
  selectors.setTestIdAttribute('data-pw');
  try {
    await expect(page.getByTestId('directions')).toHaveText('Itineraire');
  } finally {
    selectors.setTestIdAttribute('data-testid');
  }
});

Test ids and testIdAttribute

getByTestId() is the most stable locator because a test id survives changes to text, structure and role. The price is that it is not user-facing: a button with the right test id passes even when its label is wrong, so use it when role, label and text are not enough. By default it matches data-testid. If your application uses another attribute, set testIdAttribute in the configuration so the whole suite follows:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-pw',
  },
});

The Bookshelf configuration states testIdAttribute: 'data-testid' explicitly, as in the project layout page. The attribute can also be changed in code with selectors.setTestIdAttribute(), which the test above does for the duration of one check. A RegExp is accepted by getByTestId() as well (getByTestId(/^page-/) in the earlier example matched the three pager elements). The older selector shorthands id=, data-testid=, data-test-id= and data-test= still work in page.locator(), but getByTestId() is preferred.

Filtering locators

locator.filter() narrows an existing locator. Its options combine, and several filter() calls can be chained.

Option Keeps the elements that…​ Added in

hasText

contain the text (string: case-insensitive substring; RegExp as written) somewhere inside, including descendants.

Original API

hasNotText

do not contain the text.

1.33

has

contain an element matching the inner locator.

Original API

hasNot

do not contain an element matching the inner locator.

1.33

visible

are visible (true) or invisible (false).

1.51

The inner locator of has and hasNot is relative: it is queried starting from each element matched by the outer locator, not from the document root, and it must live in the same frame (it cannot contain a FrameLocator). A filter such as has: page.getByRole('list').getByText('Product 2') therefore never matches when the outer locator is a list item, because the list is outside the item.

test('filter by text, hasNotText, has and hasNot', async ({ page }) => {
  const cards = page.getByTestId('book-card');
  const fiction = cards.filter({ hasText: 'Fiction' });
  await expect(fiction).toHaveCount(2);
  await expect(cards.filter({ hasNotText: 'Fiction' })).toHaveCount(4);
  await expect(cards.filter({ hasText: /\$9\.99/ })).toHaveCount(2);

  const withTechnologyTitle = cards.filter({
    has: page.getByRole('heading', { name: 'The Quiet Compiler' }),
  });
  await expect(withTechnologyTitle).toHaveCount(1);
  await expect(cards.filter({ hasNot: page.getByRole('heading', { name: 'The Quiet Compiler' }) })).toHaveCount(5);

  // Chained filters narrow the match step by step.
  const card = cards
    .filter({ hasText: 'Fiction' })
    .filter({ has: page.getByRole('link', { name: 'Salt and Signal' }) });
  await expect(card.getByTestId('book-author')).toHaveText('Ines Okafor');
});

Chaining, and, or, visible and within

Chaining (page.getByTestId('book-card').getByRole('link')) searches inside each match of the previous locator and is the everyday way to scope a query to a card, a row or a form.

  • locator.and(other) keeps the elements that match both locators: getByRole('button').and(getByTitle('Subscribe')).

  • locator.or(other) matches either alternative, or both. Use it to wait for one of two outcomes (a button or a security dialog). When both are present the combined locator resolves to two elements, so add .first() before acting on it, or the strictness rule throws.

  • locator.visible() (new in 1.63) matches only visible elements. It replaces the :visible CSS pseudo-class and the filter({ visible: true }) form for the common case; use filter({ visible: false }) to select the invisible ones. Visibility is evaluated each time the locator is used, not when visible() is called.

  • locator.within(parent) (new in 1.64) reads in the natural order, "this button, within that dialog": saveButton.within(dialog). Relative methods such as nth() and first() are resolved separately inside each parent, so getByRole('cell').nth(2).within(getByRole('row')) is the third cell of every row, which is how a table column is addressed.

test('and, or, visible and within', async ({ page }) => {
  // and: both conditions must hold for the same element.
  const searchButton = page.getByRole('button').and(page.getByTestId('search-submit'));
  await expect(searchButton).toHaveText('Search');

  // or: either alternative; both may match, so strict mode needs first() when both are present.
  const next = page.getByTestId('page-next');
  const prev = page.getByTestId('page-prev');
  await expect(next.or(prev)).toHaveCount(2);
  await expect(next.or(prev).first()).toBeVisible();

  // visible: the signed-out page has an error paragraph that is hidden, then shown after a failed sign-in.
  await page.goto('/signin');
  const errors = page.getByTestId('signin-error');
  await expect(errors.visible()).toHaveCount(0);
  await page.getByLabel('Username').fill('reader');
  await page.getByLabel('Password').fill('wrong-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(errors.visible()).toHaveText('Invalid username or password');
  await expect(page.getByTestId('signin-error').filter({ visible: true })).toHaveCount(1);

  // within (1.64): "this author paragraph, within that card".
  await page.goto('/books');
  const secondCard = page.getByTestId('book-card').nth(1);
  const authorInSecondCard = page.getByTestId('book-author').within(secondCard);
  await expect(authorInSecondCard).toHaveText('Ilan Brooks');
  // Relative locators resolve inside each parent: the first author of every card.
  const firstParagraphOfEveryCard = page.locator('p').first().within(page.getByTestId('book-card'));
  await expect(firstParagraphOfEveryCard).toHaveCount(6);
});

CSS, XPath and the legacy selector engines

page.locator(selector) accepts a selector string. Playwright detects CSS and XPath automatically: a string starting with // or .. is XPath, anything else is CSS, and the prefixes css= and xpath= make the choice explicit. Both are tied to the DOM structure, so long chains such as #tsf > div:nth-child(2) > div.A8SBwf > ... break whenever the markup changes. Use them for what has no user-facing handle.

Playwright augments CSS in two ways. CSS selectors pierce open shadow roots, and a set of custom pseudo-classes is available:

Pseudo-class or engine Meaning

:has-text("x")

The element contains the text somewhere inside (case-insensitive, trimmed substring). Combine it with a tag or class, otherwise it also matches <body>.

:text("x")

The smallest element containing the text (case-insensitive substring).

:text-is("x")

The smallest element with exactly that text (case-sensitive, whole string after trimming).

:text-matches("re", "i")

The smallest element whose text matches the regular expression. Backslashes need doubling inside a CSS string, and doubling again inside a JavaScript string.

:has(selector), :is(a, b)

Standard relational and list pseudo-classes. A comma-separated list also matches any alternative.

:visible

Only visible elements. Prefer locator.visible().

:nth-match(selector, n)

The n-th match (one-based) of the selector across the whole page, not among siblings. Also useful with waitFor() to wait for a number of elements.

:right-of(), :left-of(), :above(), :below(), :near()

Layout selectors based on the bounding box, with an optional maximum distance. Deprecated and not recommended; matches can change with a one-pixel layout shift.

nth=0, nth=-1

Zero-based index selector, used as locator('button').locator('nth=-1').

xpath=...

XPath, equivalent to Document.evaluate. Pipe unions (a|b) work. XPath does not pierce shadow roots.

text=Log in, text="Log in", text=/Log\s*in/i

Legacy text engine. Use getByText() instead.

id=, data-testid=, data-test-id=, data-test=

Legacy attribute shorthands. Use getByTestId() instead.

selector1 >> selector2

Legacy chaining. Use chained locators instead. A leading * on a part (*css=article >> text=Hello) captures that intermediate element, for which filter({ has }) is the replacement.

test('CSS, XPath and legacy selector engines', async ({ page }) => {
  await page.goto('/books');
  await expect(page.locator('css=li.card')).toHaveCount(6);
  await expect(page.locator('li.card')).toHaveCount(6);
  await expect(page.locator('//li[@data-book-id="2"]')).toHaveCount(1);
  await expect(page.locator('xpath=//li[@data-book-id="2"]//h3')).toHaveText('Rivers of Static');
  await expect(page.locator('li.card:has-text("Fiction")')).toHaveCount(2);
  await expect(page.locator('li.card:has(a[href="/books/5"])')).toHaveCount(1);
  await expect(page.locator(':nth-match(li.card, 3)')).toHaveAttribute('data-book-id', '3');
  await expect(page.locator('li.card').locator('nth=-1')).toHaveAttribute('data-book-id', '6');
  await expect(page.locator('li.card >> text=Salt and Signal')).toHaveCount(1);
  await expect(page.locator('data-testid=search-input')).toHaveCount(1);
  await expect(page.locator('id=q')).toHaveCount(1);
  // Parent of a child: prefer filter({ has }), fall back to xpath=..
  const parentByFilter = page.getByTestId('book-card').filter({ has: page.getByText('Rivers of Static') });
  const parentByXpath = page.getByText('Rivers of Static').locator('xpath=../..');
  await expect(parentByFilter).toHaveAttribute('data-book-id', '2');
  await expect(parentByXpath).toHaveAttribute('data-book-id', '2');
});
test('Playwright CSS extensions: text pseudo-classes, :visible and layout selectors', async ({ page }) => {
  await page.goto('/signin');
  await expect(page.locator('main :text("sign in")')).toHaveCount(2);
  await expect(page.locator('main :text-is("Sign in")')).toHaveCount(2);
  await expect(page.locator('main :text-is("Sign")')).toHaveCount(0);
  await expect(page.locator('button:text-matches("sign\\\\s*in", "i")')).toHaveCount(1);
  await expect(page.locator('form:has-text("Username")')).toHaveCount(1);
  // :visible hides the [hidden] paragraph; locator.visible() is the recommended spelling.
  await expect(page.locator('form > p:visible')).toHaveCount(0);
  await expect(page.locator('form > p').visible()).toHaveCount(0);
  // Layout selectors are deprecated, but still work: the input to the right of or below a label.
  await expect(page.locator('input:below(:text("Username"))').first()).toHaveAttribute('id', 'username');
  await expect(page.locator('input:near(:text("Password"), 120)').first()).toHaveAttribute('id', 'password');
});

Two retargeting rules from the same guide are worth knowing. To reach the parent of an element, filter({ has }) is better than xpath=.., as the parent test above shows. And an action aimed at a label is retargeted to its control: click(), fill(), inputValue(), selectText(), setInputFiles() and selectOption() on the label’s text act on the associated input, while an assertion such as toHaveText() still reads the label itself.

Shadow DOM

All locators pierce open shadow roots by default, so a locator for text inside a web component is written as if the shadow root were not there. Two exceptions: XPath does not pierce shadow roots, and closed-mode shadow roots are not supported.

test('shadow DOM is pierced by default', async ({ page }) => {
  await page.setContent(`
    <x-details>
      <div>Title</div>
    </x-details>
    <script>
      const host = document.querySelector('x-details');
      const root = host.attachShadow({ mode: 'open' });
      root.innerHTML = '<div id="inner-details"><button data-testid="inner-button">Details</button></div>';
    </script>
  `);
  await expect(page.getByText('Details')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Details' })).toHaveCount(1);
  await expect(page.getByTestId('inner-button')).toBeVisible();
  await expect(page.locator('#inner-details')).toHaveCount(1);
  await expect(page.locator('x-details', { hasText: 'Details' })).toHaveCount(1);
  // XPath does not pierce shadow roots.
  await expect(page.locator('//div[@id="inner-details"]')).toHaveCount(0);
});

Playwright 1.64 added an includeShadow option to page.content() so the serialised HTML contains the shadow content too (see What changed recently).

Aria refs: page.getByRef()

A page snapshot in the ai mode annotates every node with a short reference such as [ref=e12], and Playwright 1.64’s page.getByRef('e12') resolves that reference back to a locator. This is the mechanism behind tools that let a language model read a page snapshot and then act on one element of it (see Playwright MCP & CLI). The ai mode also includes the content of iframes. Refs for nodes outside the main frame carry a prefix, as in the reference example f1e3; in the run for this page a snapshot taken after a navigation also used an f1 prefix, so treat a ref as an opaque string that is only valid for the snapshot that reported it.

$ node -e "..."   # page.ariaSnapshot({ mode: 'ai' }) on http://localhost:3912/books, first lines
- generic [active] [ref=e1]:
  - link "Skip to content" [ref=e2] [cursor=pointer]:
  - banner [ref=e3]:
    - link "Bookshelf" [ref=e4] [cursor=pointer]:
    - navigation "Main" [ref=e5]:
  - main [ref=e9]:
    - heading "Catalogue" [level=1] [ref=e10]
    - search [ref=e11]:
      - searchbox "Search books" [ref=e13]
      - button "Search" [ref=e14] [cursor=pointer]

The listing is abbreviated: the real output includes the /url lines under each link and the full list of books. The next test reads a snapshot, picks the ref of the Search button, and clicks it through getByRef().

test('page.getByRef uses refs from an AI-mode aria snapshot', async ({ page }) => {
  await page.goto('/books');
  const snapshot = await page.ariaSnapshot({ mode: 'ai' });
  const line = snapshot.split('\n').find((l) => l.includes('button "Search"'))!;
  const ref = /\[ref=(e\d+)\]/.exec(line)![1];
  expect(ref).toMatch(/^e\d+$/);
  await page.getByLabel('Search books').fill('quiet');
  await page.getByRef(ref).click();
  await expect(page).toHaveURL(/q=quiet/);
});

A ref resolves against the latest snapshot taken in the element’s frame, so take a new snapshot after the page changes and do not store refs across navigations. Refs are for tools and scripts that work from a snapshot; in a regular test suite, role, label and test id locators are more durable. Aria snapshots as assertions (toMatchAriaSnapshot, ariaSnapshot() with depth, mode, boxes, and ariaSnapshotJSON() from 1.63) are described in Playwright Assertions.

Strictness and picking by position

Every locator operation that implies one element (click, fill, textContent, hover) throws a strict mode violation when the locator resolves to more than one. Multi-element operations (count(), all(), evaluateAll(), expect(locator).toHaveCount()) are fine with several matches. The error lists the matches and, for each, a better locator:

locator.click: Error: strict mode violation: getByTestId('book-card') resolved to 6 elements:
    1) <li class="card" data-book-id="1" data-testid="book-card">…</li> aka getByText('The Quiet CompilerMara')
    2) <li class="card" data-book-id="2" data-testid="book-card">…</li> aka getByText('Rivers of StaticIlan')
    ...
Call log:
  - waiting for getByTestId('book-card')

The fix is a more precise locator, not a way around the check. locator.first(), locator.last() and locator.nth(n) (zero-based, negative from the end) opt out of strictness deliberately. Use them when the order is the contract (the first search result, the last row) and avoid them for convenience, because the day the page gains another element the test silently acts on a different one.

test('strictness, first, last and nth', async ({ page }) => {
  const cards = page.getByTestId('book-card');
  // Several matches: acting on the locator is ambiguous.
  await expect(cards.click({ timeout: 1000 })).rejects.toThrow(/strict mode violation/);
  // Counting is a multiple-element operation, so it is allowed.
  expect(await cards.count()).toBe(6);
  await expect(cards.first().getByTestId('book-title')).toHaveText('The Quiet Compiler');
  await expect(cards.last().getByTestId('book-title')).toHaveText('Small Systems, Big Days');
  await expect(cards.nth(2).getByTestId('book-title')).toHaveText('A Field Guide to Clouds');
  await expect(cards.nth(-1).getByTestId('book-title')).toHaveText('Small Systems, Big Days');
});
test('list helpers: toHaveText with an array, all, evaluateAll', async ({ page }) => {
  const titles = page.getByTestId('book-title');
  await expect(titles).toHaveText([
    'The Quiet Compiler',
    'Rivers of Static',
    'A Field Guide to Clouds',
    'Salt and Signal',
    'The Lantern Index',
    'Small Systems, Big Days',
  ]);
  const texts: string[] = [];
  for (const title of await titles.all()) texts.push((await title.textContent()) ?? '');
  expect(texts).toHaveLength(6);
  const hrefs = await page.getByTestId('book-card').getByRole('link').evaluateAll(
    (links) => links.map((a) => a.getAttribute('href')),
  );
  expect(hrefs).toEqual(['/books/1', '/books/2', '/books/3', '/books/4', '/books/5', '/books/6']);
});

locator.all() snapshots the matches at one moment and returns a locator per element; it does not wait for the list to settle, so assert the count first (await expect(locator).toHaveCount(6)) before iterating. Inside evaluateAll() the callback runs in the page and can use any DOM API.

Actionability checks and auto-waiting

Before each action Playwright waits for a set of checks on the resolved element. If they do not all pass within the action timeout the action fails with a TimeoutError and a call log that shows what it was waiting for. The checks:

Check Definition

Visible

The element has a non-empty bounding box and no visibility: hidden. Elements with zero size or display: none are not visible; elements with opacity: 0 are.

Stable

The element kept the same bounding box for at least two consecutive animation frames, so it is not animating.

Receives events

The element is the hit target at the action point. If an overlay would capture the click at that point, the check fails.

Enabled

The element is not disabled: a button, select, input, textarea, option or optgroup with disabled, or inside a disabled fieldset, or a descendant of an element with aria-disabled="true".

Editable

Enabled and not read-only: no readonly attribute on select, input or textarea, and no aria-readonly="true" on a role that supports it.

Which checks each action performs:

Action Visible Stable Receives events Enabled Editable

check(), click(), dblclick(), setChecked(), tap(), uncheck()

yes

yes

yes

yes

-

hover(), dragTo()

yes

yes

yes

-

-

screenshot()

yes

yes

-

-

-

fill(), clear()

yes

-

-

yes

yes

selectOption()

yes

-

-

yes

-

selectText()

yes

-

-

-

-

scrollIntoViewIfNeeded()

-

yes

-

-

-

blur(), dispatchEvent(), focus(), press(), pressSequentially(), setInputFiles()

-

-

-

-

-

Before the checks, pointer actions also wait for the locator to resolve to exactly one element, scroll it into view (1.62 added scroll: 'none' to opt out), and retry if the element detaches in the meantime. You never need to wait for the page to "finish loading" before a click: the action waits for the element it needs.

test('click waits until the button becomes enabled', async ({ page }) => {
  await page.setContent(`
    <button id="go" disabled onclick="document.title='clicked'">Go</button>
    <script>setTimeout(() => document.getElementById('go').disabled = false, 1500)</script>
  `);
  const started = Date.now();
  await page.getByRole('button', { name: 'Go' }).click();
  expect(Date.now() - started).toBeGreaterThan(1000);
  await expect(page).toHaveTitle('clicked');
});
test('click waits for an overlay to go away, force skips the check', async ({ page }) => {
  await page.setContent(`
    <button id="pay" onclick="window.paid=(window.paid||0)+1">Pay</button>
    <div id="overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.2)"></div>
    <script>setTimeout(() => document.getElementById('overlay').remove(), 1200)</script>
  `);
  await page.getByRole('button', { name: 'Pay' }).click();
  expect(await page.evaluate(() => (window as any).paid)).toBe(1);

  // A permanent overlay: the action times out and the log says who intercepts the pointer events.
  await page.evaluate(() => {
    const o = document.createElement('div');
    o.id = 'blocker';
    o.style.cssText = 'position:fixed;inset:0';
    document.body.append(o);
  });
  await expect(page.getByRole('button', { name: 'Pay' }).click({ timeout: 1500 })).rejects.toThrow(/intercepts pointer events/);
  await page.getByRole('button', { name: 'Pay' }).click({ force: true });
  // With force the click is dispatched at the point, where the overlay is the target, so the handler did not run.
  expect(await page.evaluate(() => (window as any).paid)).toBe(1);
  // dispatchEvent skips every check and fires the event on the element itself.
  await page.getByRole('button', { name: 'Pay' }).dispatchEvent('click');
  expect(await page.evaluate(() => (window as any).paid)).toBe(2);
});
test('stable: an animated button is clicked once it stops moving', async ({ page }) => {
  await page.setContent(`
    <style>
      #moving { position: absolute; left: 0; animation: slide 1s linear forwards; }
      @keyframes slide { from { left: 0 } to { left: 300px } }
    </style>
    <button id="moving" onclick="window.at = this.getBoundingClientRect().left">Move me</button>
  `);
  await page.getByRole('button', { name: 'Move me' }).click();
  expect(await page.evaluate(() => (window as any).at)).toBe(300);
});
test('fill needs an editable element, trial only checks', async ({ page }) => {
  await page.setContent(`<input id="ro" readonly value="fixed"><input id="free"><button disabled>Off</button>`);
  await expect(page.locator('#ro').fill('x', { timeout: 1000 })).rejects.toThrow(/readonly|not editable/);
  await page.locator('#free').fill('ok', { timeout: 1000 });
  await page.locator('#free').click({ trial: true });
  await expect(page.getByRole('button', { name: 'Off' }).click({ timeout: 1000 })).rejects.toThrow(/not enabled/);
  await expect(page.getByRole('button', { name: 'Off' })).toBeDisabled();
  await expect(page.locator('#ro')).not.toBeEditable();
});
test('visibility rules: zero size and display none are not visible, opacity 0 is', async ({ page }) => {
  await page.setContent(`
    <div id="none" style="display:none">none</div>
    <div id="zero" style="width:0;height:0"></div>
    <div id="opaque" style="opacity:0">opaque</div>
    <div id="hidden" style="visibility:hidden">hidden</div>
  `);
  expect(await page.locator('#none').isVisible()).toBe(false);
  expect(await page.locator('#zero').isVisible()).toBe(false);
  expect(await page.locator('#hidden').isVisible()).toBe(false);
  expect(await page.locator('#opaque').isVisible()).toBe(true);
  await page.locator('#none').waitFor({ state: 'attached' });
  await page.locator('#none').waitFor({ state: 'hidden' });
});

When the overlay never goes away, the call log tells you which element is in the way and keeps retrying, with growing pauses, until the timeout. This is the real output for a button covered by a full-screen <div> (Chromium):

locator.click: Timeout 1500ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Pay' })
    - locator resolved to <button id="pay">Pay</button>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <div></div> intercepts pointer events
    - retrying click action
    - waiting 20ms
    ...

A disabled button produces element is not enabled in the same log.

Forcing, trial runs and dispatching events

  • force: true skips the non-essential checks (visibility is still needed to compute the click point; "receives events" is skipped). In the overlay test above the forced click is dispatched at the point where the overlay is the hit target, so the button’s own handler does not run: force can hide a real bug where the element is covered.

  • trial: true performs the checks and skips the action, which is a way to wait until an element is ready without touching it. Modifier keys are still pressed.

  • locator.dispatchEvent('click') fires the DOM event on the element itself with no checks at all. It tests the handler, not the user experience.

Overlays that appear at unpredictable times

For banners, consent dialogs and promotions that can appear at any moment, page.addLocatorHandler(locator, handler) (1.44 and later; the times and noWaitAfter options and the locator argument arrived in later releases) runs the handler whenever the overlay blocks an action, then retries the action. Remove it with page.removeLocatorHandler().

test('addLocatorHandler dismisses an overlay that appears at an unpredictable time', async ({ page }) => {
  await page.setContent(`
    <button id="buy" onclick="window.bought = true">Buy now</button>
    <div id="promo" role="dialog" aria-label="Newsletter" style="position:fixed;inset:0;background:white">
      <button onclick="document.getElementById('promo').remove()">No thanks</button>
    </div>
  `);
  const handlerRuns: number[] = [];
  await page.addLocatorHandler(page.getByRole('dialog', { name: 'Newsletter' }), async (overlay) => {
    handlerRuns.push(1);
    await overlay.getByRole('button', { name: 'No thanks' }).click();
  });
  // The click is blocked by the overlay; Playwright runs the handler, then retries the click.
  await page.getByRole('button', { name: 'Buy now' }).click();
  expect(await page.evaluate(() => (window as any).bought)).toBe(true);
  expect(handlerRuns).toHaveLength(1);
  await page.removeLocatorHandler(page.getByRole('dialog', { name: 'Newsletter' }));
});

Timeouts

In the test runner the action timeout is 0 (no limit) unless actionTimeout is set in use, so an action is bound by the test timeout (30 s by default); each web-first assertion is bound by the expect timeout (5 s by default). Individual calls accept timeout, and since 1.62 most calls and assertions accept signal (an AbortSignal) to cancel them. The configuration of these values is covered in Configuration, Fixtures & Projects.

Input actions

Action What it does and its main options

fill(value)

Focuses the element, sets its value and dispatches an input event. Works on input, textarea and [contenteditable] (a label that has an associated control fills the control). An empty string clears. Date, time and datetime-local inputs take ISO strings such as 2020-03-02T05:15. Options: force, timeout, signal.

clear()

Same as fill('').

pressSequentially(text, { delay })

Types character by character with keydown, keypress/input and keyup. Use it only when the page has special keyboard handling (as-you-type search, masks); otherwise fill() is faster and more reliable. It replaces the deprecated type().

press(key)

One keystroke: a logical key name (Enter, Tab, Escape, Backspace, ArrowDown, PageUp, F1 to F12, Digit0, KeyA) or a single character, with modifiers joined by plus signs: Control+ArrowRight, Shift+A, ControlOrMeta+A. The character is case-sensitive, so Shift+a types a lower-case a.

click()

Options: button (left, right, middle), clickCount, delay (ms between mousedown and mouseup), force, modifiers (Alt, Control, ControlOrMeta, Meta, Shift), position ({ x, y } from the top-left of the padding box), steps (interpolated mousemoves, 1.57), scroll (auto or none, 1.62), trial, timeout. noWaitAfter is deprecated.

dblclick(), tap()

Same checks and similar options. tap() needs a context with hasTouch: true.

hover()

Moves the pointer over the element, for menus and tooltips.

check(), uncheck(), setChecked(state)

For input[type=checkbox], input[type=radio] and [role=checkbox]. They verify the final state and throw if the click did not produce it.

selectOption(values)

Selects options in a <select> by value, label, index or element: selectOption('blue'), selectOption({ label: 'Blue' }), selectOption(['red', 'green']) for multiple selects.

setInputFiles(files)

Uploads into input[type=file]: a path, an array of paths, a directory (for webkitdirectory), an in-memory { name, mimeType, buffer } object, or [] to clear. Relative paths resolve from the current working directory.

dragTo(target)

Hovers the source, presses the mouse, moves to the target and releases. Options sourcePosition, targetPosition, force, steps, trial.

focus(), blur()

Focus and blur the element, for pages that react to focus.

scrollIntoViewIfNeeded()

Scrolls the element into view. Actions already do it, so it is mainly for infinite lists and screenshots. page.mouse.wheel(dx, dy) and locator.evaluate(el => el.scrollTop += 100) give finer control.

dispatchEvent(type)

Fires a DOM event directly, with no checks.

The first test signs in with fill() and click() and then adds a review with selectOption(), fill(), clear(), pressSequentially() and press().

test('sign in and add a review with fill, selectOption and click', async ({ page }) => {
  await page.goto('/signin');
  await page.getByLabel('Username').fill('reader');
  await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByTestId('account-heading')).toContainText('reader');

  await page.goto('/books/3');
  await page.getByLabel('Rating').selectOption('4');
  await page.getByLabel('Rating').selectOption({ label: '4' });
  await page.getByLabel('Your review').fill('A lovely, detailed field guide.');
  await expect(page.getByLabel('Your review')).toHaveValue('A lovely, detailed field guide.');
  await page.getByLabel('Your review').clear();
  await page.getByLabel('Your review').pressSequentially('Typed key by key', { delay: 10 });
  await page.getByLabel('Your review').press('ControlOrMeta+A');
  await page.getByLabel('Your review').press('Backspace');
  await expect(page.getByLabel('Your review')).toHaveValue('');
  await page.getByLabel('Your review').fill('A lovely, detailed field guide.');
  await page.getByRole('button', { name: 'Submit review' }).click();
  await expect(page.getByTestId('review').filter({ hasText: 'lovely' })).toHaveAttribute('data-state', 'saved');
});
test('keyboard: press on the search field submits the form', async ({ page }) => {
  await page.goto('/books');
  await page.getByRole('searchbox', { name: 'Search books' }).fill('signal');
  await page.getByRole('searchbox', { name: 'Search books' }).press('Enter');
  await expect(page).toHaveURL(/q=signal/);
  await expect(page.getByTestId('book-card')).toHaveCount(1);
  await page.getByRole('searchbox').focus();
  await expect(page.getByRole('searchbox')).toBeFocused();
});
test('hover, click options and scrolling', async ({ page }) => {
  await page.goto('/books');
  const card = page.getByTestId('book-card').nth(4);
  await card.hover();
  await card.getByRole('link').click({ position: { x: 5, y: 5 }, delay: 20 });
  await expect(page).toHaveURL(/\/books\/5$/);
  await page.getByTestId('theme-toggle').click();
  await expect(page.locator('html')).toHaveAttribute('data-theme', /dark|light/);
  await page.getByTestId('theme-toggle').click({ modifiers: ['Shift'] });
  await page.getByText('Fictional data').scrollIntoViewIfNeeded();
  await expect(page.getByText('Fictional data')).toBeInViewport();
  // dblclick, right click and dispatchEvent need a control that listens to them.
  await page.getByTestId('theme-toggle').dispatchEvent('click');
});

Bookshelf has no checkboxes, radio buttons, multi-selects, file inputs or drag targets, so the next tests use documents built with page.setContent() and run them in the same way.

test('check, uncheck and radio buttons', async ({ page }) => {
  await page.setContent(`
    <label><input type="checkbox" name="agree"> I agree to the terms above</label>
    <label><input type="radio" name="size" value="m"> M</label>
    <label><input type="radio" name="size" value="xl"> XL</label>
  `);
  const agree = page.getByLabel('I agree to the terms above');
  await agree.check();
  await expect(agree).toBeChecked();
  await agree.uncheck();
  await expect(agree).not.toBeChecked();
  await agree.setChecked(true);
  await page.getByLabel('XL').check();
  await expect(page.getByLabel('XL')).toBeChecked();
  await expect(page.getByRole('radio', { name: 'M' })).not.toBeChecked();
});
test('selectOption with several values', async ({ page }) => {
  await page.setContent(`
    <label>Choose colors
      <select multiple>
        <option value="red">Red</option><option value="green">Green</option><option value="blue">Blue</option>
      </select>
    </label>
  `);
  const colors = page.getByLabel('Choose colors');
  await colors.selectOption(['red', 'blue']);
  await expect(colors).toHaveValues(['red', 'blue']);
  await colors.selectOption({ label: 'Green' });
  await expect(colors).toHaveValues(['green']);
});
test('setInputFiles: path, several files, buffer and clearing', async ({ page }) => {
  await page.setContent(`
    <label>Upload files <input type="file" multiple></label>
    <ul id="names"></ul>
    <script>
      document.querySelector('input').addEventListener('change', (e) => {
        document.getElementById('names').innerHTML =
          [...e.target.files].map((f) => '<li>' + f.name + ' (' + f.size + ' bytes)</li>').join('');
      });
    </script>
  `);
  const dir = mkdtempSync(path.join(tmpdir(), 'upload-'));
  const fileOnDisk = path.join(dir, 'notes.txt');
  writeFileSync(fileOnDisk, 'Hello, Bookshelf');
  const input = page.getByLabel('Upload files');
  await input.setInputFiles(fileOnDisk);
  await expect(page.getByRole('listitem')).toHaveText('notes.txt (16 bytes)');
  await input.setInputFiles({ name: 'cover.txt', mimeType: 'text/plain', buffer: Buffer.from('this is test') });
  await expect(page.getByRole('listitem')).toHaveText('cover.txt (12 bytes)');
  await input.setInputFiles([]);
  await expect(page.getByRole('listitem')).toHaveCount(0);
});
test('file chooser event for inputs that are created dynamically', async ({ page }) => {
  await page.setContent(`<button onclick="const i=document.createElement('input');i.type='file';i.click()">Attach</button>`);
  const chooserPromise = page.waitForEvent('filechooser');
  await page.getByRole('button', { name: 'Attach' }).click();
  const chooser = await chooserPromise;
  expect(chooser.isMultiple()).toBe(false);
  await chooser.setFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') });
});
test('dragTo and manual dragging', async ({ page }) => {
  await page.setContent(`
    <style>#item{width:60px;height:30px;background:#cde} #zone{width:120px;height:80px;border:1px solid;margin-top:20px}</style>
    <div id="item" draggable="true">item</div>
    <div id="zone">drop here</div>
    <p id="log"></p>
    <script>
      const zone = document.getElementById('zone');
      zone.addEventListener('dragover', (e) => e.preventDefault());
      zone.addEventListener('drop', (e) => { e.preventDefault(); document.getElementById('log').textContent = 'dropped'; });
    </script>
  `);
  await page.locator('#item').dragTo(page.locator('#zone'));
  await expect(page.locator('#log')).toHaveText('dropped');
  await page.locator('#log').evaluate((el) => (el.textContent = ''));
  await page.locator('#item').hover();
  await page.mouse.down();
  await page.locator('#zone').hover();
  await page.locator('#zone').hover();
  await page.mouse.up();
  await expect(page.locator('#log')).toHaveText('dropped');
});

In the manual drag, the drop zone is hovered twice because pages that depend on dragover need at least two mouse moves to receive it in every browser.

The remaining snippets from the actions guide were not run for this page, because Bookshelf and the lab documents have no control that would show an observable result (a context with touch support, a date picker, a scrolling container). They are written as documented:

await page.getByLabel('Birth date').fill('2020-02-02');                // date input
await page.getByLabel('Local time').fill('2020-03-02T05:15');           // datetime-local
await page.getByText('Item').dblclick();                                // double click
await page.getByText('Item').click({ button: 'right' });                // context menu
await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] });   // Ctrl on Windows and Linux, Cmd on macOS
await page.getByLabel('Upload directory').setInputFiles(path.join(__dirname, 'mydir'));
await page.getByTestId('scrolling-container').hover();
await page.mouse.wheel(0, 10);                                          // scroll with the wheel

Frames

A page has a main frame and optionally child frames from <iframe> elements. Locators on the page do not look inside iframes, so go through page.frameLocator(selector), which returns a FrameLocator that offers the same getBy* methods. A frame locator is as lazy as a locator: it waits for the iframe to exist and load when you act. Its counterpart locator.contentFrame() turns a locator for an <iframe> into a FrameLocator, and frameLocator.owner() goes the other way. Use the Frame object (page.frame({ name }), page.frame({ url }), page.frames(), frame.parentFrame()) when you need frame-level APIs such as frame.waitForLoadState(). Playwright does not support locators that span two frames in has, hasNot or and.

test('frames: frameLocator, page.frame and nested frames', async ({ page }) => {
  await serveLab(page, {
    'outer.html': `<h1>Outer</h1>
      <iframe name="frame-login" class="frame-class" src="/lab/login.html" title="Login frame"></iframe>`,
    'login.html': `<form><label>User Name <input id="username-input"></label>
      <button type="button" onclick="document.title='hello ' + document.getElementById('username-input').value">Sign in</button></form>`,
  });
  await page.goto('/lab/outer.html');

  const login = page.frameLocator('.frame-class');
  await login.getByLabel('User Name').fill('John');
  await login.getByRole('button', { name: 'Sign in' }).click();
  // frameLocator by title, and an assertion inside the frame.
  await expect(page.frameLocator('iframe[title="Login frame"]').getByLabel('User Name')).toHaveValue('John');
  await expect(page.locator('iframe').contentFrame().getByLabel('User Name')).toHaveValue('John');

  // Frame objects: by name attribute or by URL.
  const frame = page.frame('frame-login')!;
  await frame.fill('#username-input', 'Mary');
  expect(page.frame({ url: /login\.html$/ })).toBe(frame);
  expect(page.frames().map((f) => f.name())).toEqual(['', 'frame-login']);
  expect(frame.parentFrame()).toBe(page.mainFrame());
});

The lab document for the test is served by the serveLab() helper from The examples on this page. Aria snapshots in ai mode include iframe content, with frame-prefixed refs, so page.getByRef() also works across frames.

Dialogs

alert(), confirm(), prompt() and beforeunload dialogs block the page until handled. By default Playwright dismisses them automatically (a confirm() returns false). To control them, register a dialog listener before the action that triggers the dialog and make it accept or dismiss: a listener that only logs the message leaves the dialog open and the triggering action hangs. Useful members of the Dialog object are type(), message(), defaultValue(), accept(promptText) and dismiss(). Use page.once() for one-off handling.

test('dialogs: accept, dismiss, prompt and the default behaviour', async ({ page }) => {
  await serveLab(page, {
    'dialogs.html': `
      <button onclick="document.getElementById('out').textContent = 'alert closed'; alert('Saved!')">Alert</button>
      <button onclick="document.getElementById('out').textContent = String(confirm('Delete this review?'))">Confirm</button>
      <button onclick="document.getElementById('out').textContent = String(prompt('Your name?', 'anonymous'))">Prompt</button>
      <p id="out"></p>`,
  });
  await page.goto('/lab/dialogs.html');

  // No listener: dialogs are dismissed automatically, so confirm() returns false.
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.locator('#out')).toHaveText('false');

  // Accept a confirm and inspect it. once() removes the listener after the first dialog.
  const messages: string[] = [];
  page.once('dialog', async (dialog) => {
    messages.push(`${dialog.type()}: ${dialog.message()}`);
    await dialog.accept();
  });
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.locator('#out')).toHaveText('true');
  expect(messages).toEqual(['confirm: Delete this review?']);

  // A prompt takes the answer as the argument of accept(); defaultValue() is the pre-filled text.
  page.once('dialog', (dialog) => {
    expect(dialog.defaultValue()).toBe('anonymous');
    return dialog.accept('Reader');
  });
  await page.getByRole('button', { name: 'Prompt' }).click();
  await expect(page.locator('#out')).toHaveText('Reader');

  // A permanent listener that dismisses everything; the alert is simply closed.
  const handler = (dialog: import('@playwright/test').Dialog) => dialog.dismiss();
  page.on('dialog', handler);
  await page.getByRole('button', { name: 'Alert' }).click();
  await expect(page.locator('#out')).toHaveText('alert closed');
  page.off('dialog', handler);
});
test('beforeunload dialog with page.close({ runBeforeUnload })', async ({ page }) => {
  await serveLab(page, {
    'unload.html': `<input id="draft"><script>addEventListener('beforeunload', (e) => { e.preventDefault(); e.returnValue = ''; })</script>`,
  });
  await page.goto('/lab/unload.html');
  await page.locator('#draft').fill('unsaved');   // user activation is needed for the dialog to show
  const types: string[] = [];
  page.on('dialog', async (dialog) => {
    types.push(dialog.type());
    await dialog.accept();
  });
  await page.close({ runBeforeUnload: true });
  await page.waitForEvent('close').catch(() => {});
  expect(types).toEqual(['beforeunload']);
});
test('print dialog can be detected by replacing window.print', async ({ page }) => {
  await serveLab(page, { 'print.html': `<button onclick="window.print()">Print it!</button>` });
  await page.goto('/lab/print.html');
  await page.evaluate('(() => { window.waitForPrintDialog = new Promise((f) => (window.print = f)); })()');
  await page.getByText('Print it!').click();
  await page.waitForFunction('window.waitForPrintDialog');
});

The print dialog is not a dialog event: replace window.print with a function that resolves a promise, as in the last test, and wait for that promise. Evaluate the script after the page has loaded and before clicking the button.

Downloads

Every attachment downloaded by the page emits a download event. Files are kept in a temporary folder and deleted when the browser context that produced them closes, so save the ones you need with download.saveAs(path). As with dialogs and popups, start waiting for the event before the click that causes it and await the promise afterwards.

Download member Meaning

suggestedFilename()

Filename the browser would propose (from Content-Disposition or the download attribute).

url()

URL of the download.

saveAs(path)

Copies the file once the download finished.

path()

Path of the file in the temporary folder (waits until it finishes).

createReadStream()

Readable stream with the content.

failure()

null on success, otherwise the error string.

cancel(), delete()

Cancel a running download; remove the temporary file.

test('downloads: waitForEvent, saveAs and the Download object', async ({ page }, testInfo) => {
  await serveLab(page, {
    // The page builds a file in memory and offers it through a link with the download attribute.
    'download.html': `<a id="dl" download="books.csv">Download file</a>
      <script>
        const blob = new Blob(['id,title\\n1,The Quiet Compiler\\n'], { type: 'text/csv' });
        document.getElementById('dl').href = URL.createObjectURL(blob);
      </script>`,
  });
  await page.goto('/lab/download.html');

  // Start waiting before the click that triggers the download. Note: no await on the promise.
  const downloadPromise = page.waitForEvent('download');
  await page.getByText('Download file').click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toBe('books.csv');
  expect(download.url()).toMatch(/^blob:/);
  expect(await download.failure()).toBeNull();
  const target = testInfo.outputPath('books.csv');
  await download.saveAs(target);
  expect(readFileSync(target, 'utf8')).toBe('id,title\n1,The Quiet Compiler\n');
});

The download in this test is produced by a link that points at an in-memory Blob, which behaves the same way in Chromium and WebKit. A response with Content-Disposition: attachment served by a page.route() handler was tried first and gave inconsistent results across the two engines (a different file name, books.json, in WebKit and a cancelled download in Chromium), so for downloads from your own application point the test at the real endpoint rather than a mocked response. To persist downloads from a launched browser, set downloadsPath in browserType.launch(). For uploads, see setInputFiles() above.

Pages, tabs and popups

A BrowserContext holds any number of Page objects (tabs and popup windows). They share cookies, storage and context-level settings such as routes, viewport and locale, and each behaves as the active page, so there is no need to bring a page to the front. context.pages() lists them, and context.newPage() creates one. To catch a page that a click opens, wait for the page event of the context (all new pages, including target="_blank" links) or for the popup event of the opener page (popups opened by that page, in addition to the context event). In both cases register the wait before the click. popup.opener() returns the opener.

test('popups and new tabs: waitForEvent popup, context page event', async ({ page, context }) => {
  // Routes registered on the context apply to every page it opens, popups included.
  await serveLab(context, {
    'opener.html': `<a href="/lab/child.html" target="_blank">open new tab</a>
                     <button onclick="window.open('/lab/child.html', 'child', 'popup,width=400,height=300')">open the popup</button>`,
    'child.html': `<title>Child page</title><button>Close me</button>`,
  });
  await page.goto('/lab/opener.html');

  // A link with target=_blank is a new page in the context, and a popup of the opener.
  const pagePromise = context.waitForEvent('page');
  await page.getByText('open new tab').click();
  const newPage = await pagePromise;
  await newPage.waitForLoadState();
  await expect(newPage).toHaveTitle('Child page');
  expect(context.pages()).toHaveLength(2);
  await newPage.close();

  // window.open: the popup event is specific to the opener page.
  const popupPromise = page.waitForEvent('popup');
  await page.getByText('open the popup').click();
  const popup = await popupPromise;
  await expect(popup.getByRole('button', { name: 'Close me' })).toBeVisible();
  await expect(popup).toHaveTitle('Child page');
  expect(await popup.opener()).toBe(page);
});

Routes registered on the context apply to pages that open later, which is why the test registers serveLab() on the context: a route registered on the opener page would not serve its popup. If the action that opens the page is unknown, use context.on('page', ...) or page.on('popup', ...) as a listener and call await page.waitForLoadState() before using the page. Each test gets a fresh context from the page and context fixtures, so tabs opened by one test never leak into another (see Authentication & Isolation).

Events

Playwright emits events for requests, responses, console messages, dialogs, downloads, file choosers, new pages, workers and more. There are two patterns. Waiting (page.waitForEvent(), page.waitForRequest(), page.waitForResponse(), page.waitForURL(), page.waitForLoadState()) blocks until the event happens and returns it, and listening (page.on(), page.once(), page.off()) handles events that arrive at unknown times. The rule for waiting is always the same: create the promise first, trigger the action, then await the promise. Awaiting after the action can miss an event that already happened. Handling events with a listener forks the control flow, so the test can end before the handler finishes; prefer waiting when the order matters.

test('events: waitForEvent, on, once, off, waitForRequest and waitForResponse', async ({ page }) => {
  const requests: string[] = [];
  const onRequest = (request: import('@playwright/test').Request) => requests.push(request.url());
  page.on('request', onRequest);

  const coverRequest = page.waitForRequest('**/covers/1.svg');
  await page.goto('/books');
  expect((await coverRequest).url()).toMatch(/\/covers\/1\.svg$/);
  page.off('request', onRequest);
  const seen = requests.length;
  expect(seen).toBeGreaterThan(2);

  const responsePromise = page.waitForResponse((r) => r.url().includes('/api/books') && r.status() === 200);
  await page.evaluate(() => fetch('/api/books?q=quiet'));
  const response = await responsePromise;
  expect((await response.json()).total).toBe(1);

  // After off() the listener no longer collects anything.
  await page.goto('/books?page=2');
  expect(requests).toHaveLength(seen);

  // Console messages are an event too.
  const consolePromise = page.waitForEvent('console', (m) => m.text() === 'hello from the page');
  await page.evaluate(() => console.log('hello from the page'));
  expect((await consolePromise).type()).toBe('log');
});

Mocking and modifying the network (page.route(), context.route(), HAR replay, waitForResponse for API assertions) is covered in Network Mocking, Clock & API Testing.

Evaluating JavaScript and handles

page.evaluate(fn, arg) runs a function in the page and returns the result (serialised, with promises awaited). The test runs in Node.js and the function runs in the browser, in different processes, so variables are not shared: pass them as the single optional argument, which can be a serialisable value or a mix of values and handles. Browser globals (window, document) are available inside the function. locator.evaluate(fn, arg) receives the resolved element as the first parameter, locator.evaluateAll(fn) receives an array of all matches, and frame.evaluate() runs in a frame. page.addInitScript() (and context.addInitScript()) run a script before any page script on every navigation, which is the place to install mocks such as a fixed Math.random.

A handle is a reference, living in the Node.js process, to an object that lives in the browser. JSHandle references any JavaScript object and is created with page.evaluateHandle(); ElementHandle (a subclass) references a DOM element. Handles can be passed back into evaluate() and are released with dispose() (they also stay alive until the page navigates). Playwright discourages ElementHandle because it points at one particular element: if the page re-renders, the handle refers to a stale node, unlike a locator. Use it only for extensive DOM traversal on a static page, and create it with page.waitForSelector() so it waits for the element.

test('evaluate, evaluateHandle and handles', async ({ page }) => {
  await page.goto('/books');

  // Return values are serialised; browser globals are available inside the function.
  expect(await page.evaluate(() => document.location.pathname)).toBe('/books');
  const status = await page.evaluate(async () => (await fetch('/api/books')).status);
  expect(status).toBe(200);

  // Variables from the test must be passed as the argument.
  const data = { q: 'signal' };
  const total = await page.evaluate(async (args) => (await (await fetch('/api/books?q=' + args.q)).json()).total, data);
  expect(total).toBe(1);
  await expect(page.evaluate(() => (data as any).q)).rejects.toThrow(/ReferenceError/);

  // A locator evaluates against the element it resolves to.
  const cardIds = await page.getByTestId('book-card').evaluateAll((cards) => cards.map((c) => (c as HTMLElement).dataset.bookId));
  expect(cardIds).toEqual(['1', '2', '3', '4', '5', '6']);
  expect(await page.getByTestId('book-title').first().evaluate((el, suffix) => el.textContent + suffix, '!')).toBe('The Quiet Compiler!');

  // JSHandle: a reference to an object that stays in the page.
  const myArrayHandle = await page.evaluateHandle(() => {
    (window as any).myArray = [1];
    return (window as any).myArray as number[];
  });
  expect(await page.evaluate((a) => a.length, myArrayHandle)).toBe(1);
  await page.evaluate((arg) => arg.myArray.push(arg.newElement), { myArray: myArrayHandle, newElement: 2 });
  expect(await myArrayHandle.jsonValue()).toEqual([1, 2]);
  expect(await myArrayHandle.evaluate((a) => a.length)).toBe(2);
  await myArrayHandle.dispose();

  // ElementHandle: discouraged, and it points at one specific element.
  const handle = await page.waitForSelector('[data-testid=search-submit]');
  expect(await handle.getAttribute('type')).toBe('submit');
  expect((await handle.boundingBox())!.width).toBeGreaterThan(20);
  await handle.dispose();

  // Init scripts run before any page script, on every navigation.
  await page.addInitScript((value) => {
    Math.random = () => value;
  }, 0.42);
  await page.reload();
  expect(await page.evaluate(() => Math.random())).toBe(0.42);
});

Navigations

page.goto(url, options) opens a URL and, by default, waits for the load event. waitUntil chooses the milestone: 'load' (default), 'domcontentloaded', 'networkidle' (discouraged) or 'commit' (response headers received and navigation committed). goto() returns the main-resource Response (or null), and an HTTP error status such as 404 is a normal response, not an exception; only network-level failures throw. Relative URLs are resolved against baseURL.

After an action that navigates, such as clicking a link or submitting a form, use page.waitForURL(urlOrPattern) when the click can trigger several navigations (redirects, then a client-side route) and you want the final one. Assertions such as await expect(page).toHaveURL(...) retry too. page.goBack() and page.goForward() walk the history, and page.reload() reloads. The lifecycle of a navigation is: it starts when the URL changes or a link is clicked, it commits when the response headers are parsed (page.url() updates), then the document is parsed (domcontentloaded) and its resources load (load).

test('navigations: goto, waitUntil, waitForURL, back and forward', async ({ page }) => {
  const response = await page.goto('/books', { waitUntil: 'domcontentloaded' });
  expect(response!.status()).toBe(200);

  // Clicking a link starts a navigation; waitForURL waits for the final URL.
  await page.getByRole('link', { name: 'Next' }).click();
  await page.waitForURL('**/books?page=2');
  await expect(page.getByTestId('page-indicator')).toHaveText('Page 2 of 4');

  // Several navigations after one click: sign in goes to /account.
  await page.goto('/signin?next=/account');
  await page.getByLabel('Username').fill('reader');
  await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL(/\/account$/);

  // History navigation.
  await page.goto('/books');
  await page.getByRole('link', { name: 'Next' }).click();
  await page.waitForURL('**/books?page=2');
  await page.goBack();
  await expect(page).toHaveURL(/\/books$/);
  await page.goForward();
  await expect(page).toHaveURL(/page=2$/);

  // A 404 is a response, not an exception.
  const missing = await page.goto('/books/9999');
  expect(missing!.status()).toBe(404);

  // Lifecycle: load fires after domcontentloaded.
  const order: string[] = [];
  page.on('domcontentloaded', () => order.push('domcontentloaded'));
  page.on('load', () => order.push('load'));
  await page.goto('/books');
  await page.waitForLoadState('load');
  expect(order).toEqual(['domcontentloaded', 'load']);
});

Two behaviours from the navigation guide matter in practice:

  • Hydration. Playwright acts the moment it sees an element. On a server-rendered page whose scripts are not attached yet a click can be ignored or typed text can be wiped by the later hydration. Fix the application (enable the control only once it is interactive) rather than adding sleeps; you can reproduce the problem by emulating Slow 3G in DevTools and reloading.

  • Back/forward cache. Playwright disables the BFCache in all browsers. Even if you enable it, restoring a page from the BFCache with goBack() is not supported: no lifecycle events fire, so the Page object loses synchronisation and later interactions time out.

Headless, headed, local and CI

Locators and actions behave the same in headless and headed runs and in every browser; the differences come from timing and rendering. Headless is the default for npx playwright test; add --headed to watch a run, or --ui and the trace viewer to inspect each action (see Debugging, Tracing & Codegen). Slow CI machines make animations, network and rendering slower, which is where missing waits show: because every action waits on its own checks, a test that passes locally and fails in CI usually has an assertion on something that never becomes true there, not a missing sleep. Dialogs, downloads and popups are all handled by listeners, so they work identically headless. Keep the action and expect timeouts generous on CI instead of adding fixed waits, and read Running in CI/CD for retries and traces. Features that need a display, such as the print dialog UI, are not available headless, which is why the print example replaces window.print.

Common pitfalls

  • Not waiting for the promise before the click. waitForEvent('download'), 'popup', 'filechooser' and waitForResponse() must be created before the action and awaited after it.

  • A dialog listener that does not call accept() or dismiss(). The page stays blocked and the action hangs.

  • Using waitForTimeout() to let the page settle. Wait for an element, URL or response instead.

  • Reading a value once and asserting on it. expect(await locator.textContent()).toBe(...) does not retry; use await expect(locator).toHaveText(...).

  • Forgetting await. A locator method without await returns a promise; the test continues while the action runs.

  • Locator or assertion over several elements. Fix the locator before reaching for first().

  • Text locators that match too much. getByText('Sign in') matches a heading and a button; use getByRole() for interactive elements, or exact.

  • Backslashes in CSS text pseudo-classes. :text-matches("sign\\s*in") needs the backslash doubled for CSS and doubled again in a JavaScript string, as the lab test shows.

  • Sharing server state between workers. Bookshelf’s reset endpoint clears every session, so the examples run with one worker; a real suite isolates state per test instead (see Parallelism, Sharding & Retries).

The examples on this page

The helper used by the frame, dialog, download and popup tests, and the Playwright configuration, were:

// lab.ts
import type { BrowserContext, Page } from '@playwright/test';

/** Serves small HTML documents under /lab/<name> of the Bookshelf origin, without touching the server. */
export async function serveLab(target: Page | BrowserContext, pages: Record<string, string | { body: string; headers?: Record<string, string>; contentType?: string }>) {
  await target.route('**/lab/**', async (route) => {
    const name = new URL(route.request().url()).pathname.replace('/lab/', '');
    const entry = pages[name];
    if (entry === undefined) return route.fulfill({ status: 404, body: 'not in the lab' });
    const { body, headers, contentType } = typeof entry === 'string' ? { body: entry, headers: undefined, contentType: undefined } : entry;
    await route.fulfill({ status: 200, contentType: contentType ?? 'text/html', headers, body });
  });
}
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: '.',
  workers: 1, // one shared Bookshelf: reset clears every session
  outputDir: './test-results',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    testIdAttribute: 'data-testid',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Bookshelf was started with PORT=3912 BOOKSHELF_REVIEW_DELAY_MS=100 node server.js and the suite with BASE_URL=http://localhost:3912 npx playwright test -c ex-locators/playwright.config.ts. Local Firefox did not launch on the host used for the run, so these examples were run in Chromium and WebKit only; the API is identical in Firefox.

The complete run, in both browsers:

$ BASE_URL=http://localhost:3912 npx playwright test -c ex-locators/playwright.config.ts --reporter=line
Running 72 tests using 1 worker
  ...
  72 passed (29.2s)

The 72 results are 36 tests in each of Chromium and WebKit: 12 in the locators file, 15 in the actions file and 9 in the pages-and-events file.

What changed recently

Version Change

1.64

locator.within(parent) and page.getByRef(ref) added. page.content() gained includeShadow.

1.63

locator.visible() added as the replacement for the :visible pseudo-class. page.ariaSnapshotJSON() and locator.ariaSnapshotJSON() added.

1.62

Most actions and web-first assertions accept signal (an AbortSignal); click(), dragTo() and similar accept scroll: 'none' to skip scrolling into view.

1.60

description option for getByRole() (matching the accessible description); boxes option for aria snapshots. The long-deprecated Locator.ariaRef() was removed.

1.59

page.ariaSnapshot() added, with depth and mode options; in ai mode the snapshot carries [ref=...] markers and iframe content.

1.57

steps option on click() for interpolated pointer movement; locator.description().

1.53

locator.describe() to name a locator for reports and the trace viewer.

1.51

visible option in locator.filter().

1.44

page.addLocatorHandler() for overlays (later versions added times, noWaitAfter and a locator argument to the handler).

1.33 and 1.34

hasNot and hasNotText in filter(); locator.or() and locator.and().

1.28

exact option for getByRole() and the other getBy* locators.