Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

To try several possible selectors until one finds the intended element, query each candidate with page.$() or page.$$() and assert that the match is the right one—not merely that something matched. To inspect every element matched by one selector, use page.$$() or page.$$eval(). If the element appears later, use a locator for an interaction or page.waitForSelector() when you need to wait for DOM presence or visibility.

This is different from choosing several values in an HTML <select multiple> control; that task uses page.select().

What “multiple selectors” can mean

In Puppeteer, developers commonly mean one of two things by testing multiple selectors:

  • Try alternative selectors: query candidate strings one by one because the page may expose the same target through different markup or attributes.
  • Inspect multiple matching elements: use one selector and examine every element it matches.

Neither operation proves that the selected node is semantically correct. A selector can match an unrelated element, several elements, or a stale part of the page. Your test should assert the expected identity, count, text, or other page-specific property.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Puppeteer accepts CSS selectors by default. Its documented selector syntax also supports XPath, text, accessibility attributes, and Shadow DOM. Choose a selector based on the actual page and what the test is intended to verify; the documentation does not establish a universal reliability ranking among these approaches. See Puppeteer’s Page interactions guide (version 25.12.0).

Try several candidate selectors against the current DOM

For elements that should already be present, put candidate selectors in an array and query them in sequence. This example returns the first candidate that produces exactly one match and checks its text before treating it as the desired target.

const candidates = [
  'button[data-testid="save"]',
  'form button[type="submit"]',
  '::-p-text(Save changes)',
];

let target = null;
let matchedSelector = null;

for (const selector of candidates) {
  const matches = await page.$$(selector);

  if (matches.length === 1) {
    const text = await matches[0].evaluate(element => element.textContent?.trim());

    if (text === 'Save changes') {
      target = matches[0];
      matchedSelector = selector;
      break;
    }
  }

  await Promise.all(matches.map(element => element.dispose()));
}

if (!target) {
  throw new Error('No candidate uniquely matched the Save changes button');
}

try {
  console.log(`Matched with: ${matchedSelector}`);
  await target.click();
} finally {
  await target.dispose();
}

The first non-empty result is not automatically the right result. The example requires both uniqueness and the expected text. Replace those checks with assertions that fit your page, such as a stable test ID, accessible name, expected link destination, or a known parent container.

Choose the query API that matches the assertion

Need API What it gives you
One matching element page.$(selector) An ElementHandle for the first match, or null.
Every matching element page.$$(selector) An array of ElementHandle objects; an empty array means no matches.
Run a function on the first match page.$eval(selector, fn) The function’s result for the first match.
Run a function over all matches page.$$eval(selector, fn) The function’s result; the matching elements are passed as the first argument to fn.

The query methods inspect the page state when called; they do not wait for a future match. When you keep an ElementHandle, dispose of it when you finish using it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Test every match from one selector

When the question is “what did this selector find?” rather than “which selector should I use?”, page.$$eval() can collect the count and relevant data in one page-context callback:

const buttons = await page.$$eval('button[data-action="save"]', elements =>
  elements.map(element => ({
    text: element.textContent?.trim(),
    disabled: element.disabled,
    ariaLabel: element.getAttribute('aria-label'),
  }))
);

if (buttons.length !== 1) {
  throw new Error(`Expected one save button; found ${buttons.length}`);
}

if (buttons[0].disabled) {
  throw new Error('The save button is disabled');
}

The callback runs in the page context, so keep it self-contained: it cannot close over ordinary Node.js variables. Return serializable values such as strings, booleans, and plain objects rather than trying to return DOM elements for use in Node.js. For live element interaction, use page.$$() and work with its handles instead. The Puppeteer $$eval API reference documents the matching-elements argument.

Wait for elements rendered later

A query made immediately can return no match simply because JavaScript has not rendered the target yet. For an action such as clicking an element, Puppeteer recommends locators: the Page interactions guide says, “Locators is the recommended way to select an element and interact with it.” A locator waits for the element to be present and in a state suitable for the requested action.

await page.locator('button[data-testid="save"]').click();

Use a locator when the test’s goal is the interaction. It is not a replacement for an explicit assertion that the page contains exactly one intended match; check count or content separately if that is part of the test requirement.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use waitForSelector when presence or visibility is the test

page.waitForSelector() is a lower-level wait for a selector. The current API reference documents visible, hidden, timeout, and signal options. The default timeout is 30 seconds; timeout: 0 disables the timeout. If the selector does not appear before the timeout, the call throws. With hidden: true, it returns null if the selector is absent.

let saveButton;

try {
  saveButton = await page.waitForSelector('button[data-testid="save"]', {
    visible: true,
    timeout: 10_000,
  });

  if (!saveButton) {
    throw new Error('Save button was not present');
  }

  const text = await saveButton.evaluate(element => element.textContent?.trim());
  if (text !== 'Save changes') {
    throw new Error(`Unexpected button text: ${text}`);
  }
} finally {
  await saveButton?.dispose();
}

Set a timeout that reflects the behavior your test expects instead of silently disabling it. visible: true waits for visibility, while a plain selector wait addresses presence. A wait does not automatically retry an action that fails afterward; if you need an interaction with automatic waiting, prefer a locator. See the Puppeteer waitForSelector API reference. Check the reference for the Puppeteer version installed in your project if its documented options differ.

Use Puppeteer selector syntax when CSS is not enough

CSS is the default and is usually the clearest choice when the page exposes stable classes, IDs, or data attributes. Puppeteer’s documented selector syntax adds ways to target text, accessibility attributes, XPath, and Shadow DOM. For example, the candidate list above includes ::-p-text(Save changes), a Puppeteer text selector. Use these forms only when they fit the page markup and your test’s intent; a text match can still be ambiguous if the same wording appears more than once.

When testing candidates, keep each string in the syntax Puppeteer expects and apply the same correctness assertion to each result. Avoid treating selector syntax as a substitute for an assertion: a text, XPath, or accessibility-based match can also find the wrong node if it is not specific enough.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Do not confuse selector alternatives with selecting several form values

page.select() is for choosing option values in a matching HTML <select> element—not for trying several query selectors. For a multiple select control, pass each desired option value:

await page.select('select#colors', 'red', 'green');

The method triggers input and change events after selecting options. It throws if no matching select exists; all supplied values are considered when the select has the multiple attribute. See the Puppeteer page.select() API reference.

Troubleshoot selector tests

  • The query returns null or an empty array: The selector may not match the current DOM, or the element may not have rendered yet. Verify the markup and whether the page is still loading; for late-rendered content, use a locator or an explicit wait.
  • The selector matches more elements than expected: Tighten it with a stable attribute or container, then assert the expected count. Do not silently take the first match when uniqueness matters.
  • The selector finds an element, but it is the wrong one: Add a page-specific assertion for text, attributes, parent context, or another relevant property. Existence alone does not establish identity.
  • waitForSelector() times out: Check for a typo, an incorrect page state, or rendering that never completes. If presence is not the goal and you need an action with automatic waiting, use a locator instead.
  • An action fails after a successful wait: A wait for presence or visibility is not an automatic retry of the later action. Prefer a locator for interaction, and make the expected state explicit.
  • Handles accumulate during candidate checks: Dispose of handles returned by page.$(), page.$$(), or waitForSelector() when done. Page-evaluation methods such as $$eval() are useful when you only need extracted data.

Or skip the browser setup

If you need a clean screenshot rather than an interactive Puppeteer test, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF; its API accepts one URL. See the ScreenshotNeo documentation for options and setup.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each of those steps can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the page verdict and billing status in headers. Its MCP server includes take_screenshot, get_page_info, and capture_pdf for AI agents and MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for free and get 1,000 screenshots a month with no card.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frequently Asked Questions

Which Puppeteer version do these API details describe?

The cited current guide and API references displayed Puppeteer version 25.12.0. Confirm options against the documentation for the version installed in your project.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.