Testing Tools cypress puppeteer webdriverio javascript

XPath in Cypress, Puppeteer and WebdriverIO

Cypress needs a plugin, Puppeteer uses the xpath/ prefix, WebdriverIO detects it automatically. What each runner supports, its quirks, and the migration notes.

XPath in Cypress, Puppeteer and WebdriverIO

Three popular JavaScript runners, three different levels of XPath support. All of them ultimately call the browser’s document.evaluate, so the expressions are identical; only the wrapper differs.

Cypress

Cypress does not ship XPath support in core. Its query engine is built on jQuery-style CSS selection and its own commands (contains, within, parents, siblings), which cover most of what people use XPath for.

To use XPath you add a plugin. The historical one is cypress-xpath, later published under the Cypress org and then archived; check the current maintenance status before adopting it. Installation follows the usual pattern:

// cypress/support/e2e.js
require('cypress-xpath');
cy.xpath("//button[normalize-space()='Save']").click();
cy.xpath("//tr[td[normalize-space()='USB-C Hub']]//button[@aria-label='Remove']").click();
cy.xpath("//table[@id='orders']/tbody/tr").should('have.length', 5);

cy.xpath yields a jQuery collection like cy.get, so .should, .within, .first all chain as normal, and it retries until the assertion passes.

The Cypress way without XPath for the same three examples:

cy.contains('button', 'Save').click();
cy.contains('tr', 'USB-C Hub').find('button[aria-label="Remove"]').click();
cy.get('#orders tbody tr').should('have.length', 5);

cy.contains(selector, text) is Cypress’s answer to text matching, and .parents(), .closest(), .siblings() cover the axes. If your team is already fluent in XPath and shares locators with a Selenium suite, the plugin makes sense. Otherwise the built-ins are enough and need no dependency.

Puppeteer

Puppeteer has native XPath support. The API has changed across versions:

// older API (deprecated in recent versions)
const [button] = await page.$x("//button[normalize-space()='Save']");

// current API: xpath/ prefix on the normal query methods
const button = await page.$("xpath/.//button[normalize-space()='Save']");
const rows = await page.$$("xpath/.//table[@id='orders']/tbody/tr");
await page.waitForSelector("xpath/.//*[@role='dialog']");

Note the .// after the prefix: Puppeteer evaluates the expression relative to the element the query is called on (the document, or an ElementHandle). Puppeteer also has a P-selector syntax that mixes engines, such as ::-p-xpath(//button) and ::-p-text(Save), which can be combined with CSS in one string.

Check the version you are on; the $x method still exists in many codebases and works, but new code should use the prefix form.

WebdriverIO

WebdriverIO auto-detects XPath: any selector starting with //, ./ or ( is passed to the XPath engine.

const save = await $("//button[normalize-space()='Save']");
await save.click();

const rows = await $$("//table[@id='orders']/tbody/tr");
expect(rows).toHaveLength(5);

const card = await $("//article[.//h3[normalize-space()='USB-C Hub']]");
await card.$(".//button").click();      // relative search inside the card

WebdriverIO also has its own text-matching selector syntax (button=Save for exact text, button*=Save for partial) and deep selectors for shadow DOM (>>>), so like Cypress it often does not need XPath for simple text matches.

Comparison

RunnerXPath supportText matching alternativeShadow DOM with XPath
Cypressplugincy.contains()no; use .shadow() with CSS
Puppeteernative, xpath/ prefix::-p-text()no; use CSS or pierce/
WebdriverIOnative, auto-detected= and *= selectorsno; use shadow$()
Playwrightnative, xpath= prefixgetByText, hasTextno; chain CSS
Seleniumnativenone; XPath is the optionno; getShadowRoot() then CSS

The shadow DOM row is the same everywhere: XPath stops at the shadow boundary in every tool, because the browser’s evaluator does.

Sharing locators across runners

If you maintain an XPath library used by more than one tool, keep the expressions free of runner-specific prefixes and add the prefix at the call site:

// locators.js
export const SAVE_BUTTON = "//button[normalize-space()='Save']";

// puppeteer
await page.$(`xpath/.${SAVE_BUTTON}`);
// playwright
page.locator(`xpath=${SAVE_BUTTON}`);
// webdriverio
await $(SAVE_BUTTON);

Next Steps

  1. XPath in Playwright - The locator-first model in depth
  2. Shadow DOM and iframes - The common limitation
  3. Code Generator - Generate the runner-specific call for any XPath