From 4a71819b7feba1e9fc90a53e6dda831575162b03 Mon Sep 17 00:00:00 2001 From: developer Date: Fri, 5 Jun 2026 20:40:10 +0300 Subject: [PATCH] feat: add XPath locator support for page and locator methods --- CHANGELOG.md | 3 +++ README.md | 28 ++++++++++++++++++-- src/browser/browser.ts | 2 ++ src/browser/locator-utils.ts | 21 +++++++++++++++ src/browser/locator.ts | 13 ++++++++-- test/specs/apiCoverage.spec.ts | 47 ++++++++++++++++++++++++++++++++++ test/unit/locator.test.ts | 42 +++++++++++++++++++++++++++++- 7 files changed, 151 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdaf53f..31def98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- XPath locator support — `page.locator()`, `locator.locator()`, and `frameLocator.locator()` now accept XPath expressions in addition to CSS selectors. Prefix with `//` (e.g. `//button[@id='submit']`) or with `xpath=` (e.g. `xpath=//button[@id='submit']`). Evaluated via `document.evaluate()` using `ORDERED_NODE_SNAPSHOT_TYPE`. + ### Changed - Command log entries no longer have a coloured left border — status is conveyed by the icon only - Inline test log now collapses automatically when a test finishes, regardless of pass or fail diff --git a/README.md b/README.md index dddd8d0..63d184f 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,21 @@ console.log(resp.status()); // 200 ```ts page.locator(selector: string): Locator ``` -Match elements by CSS selector. +Match elements by CSS selector or XPath expression. Selectors prefixed with `//` or `xpath=` are treated as XPath and evaluated via `document.evaluate()`; all other strings are treated as CSS. + +```ts +// CSS selector +page.locator('#submit') +page.locator('.card, .panel') // comma-separated CSS + +// XPath — // prefix +page.locator(`//button[@id='submit']`) +page.locator(`//h2[text()='Login']`) +page.locator(`//input[contains(@placeholder,'email')]`) + +// XPath — explicit xpath= prefix +page.locator(`xpath=//button[text()='OK']`) +``` ```ts page.getByText(text: string | RegExp, opts?: { exact?: boolean }): Locator @@ -943,7 +957,17 @@ locator.nth(n: number): Locator locator.first(): Locator locator.last(): Locator locator.filter(opts: { hasText?: string | RegExp; hasNotText?: string | RegExp }): Locator -locator.locator(selector: string): Locator // scoped child query +locator.locator(selector: string): Locator // scoped child query — CSS or XPath +``` + +`locator.locator()` accepts the same CSS and XPath syntax as `page.locator()`. XPath expressions are evaluated relative to each matched root element, so descendants-only axes (`//`) search within that subtree. + +```ts +// CSS then XPath chain +page.locator('.card').locator(`//button[text()='Add']`) + +// XPath then CSS is not directly chainable, but XPath first works too +page.locator(`//section[@class='card']`).locator('button') ``` #### Actions diff --git a/src/browser/browser.ts b/src/browser/browser.ts index c224d5f..93fc99b 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -5,6 +5,7 @@ import { Route, routeHandlers as _routeHandlers, matchesRoutePattern as _matches export { Route }; import { installEventBridges as _installEventBridges, installWindowBridges as _installWindowBridges } from './bridges'; import { Locator, resolveSelector, _locatorHandlers } from './locator'; +import { isXPath, resolveXPath, queryXPath } from './locator-utils'; import { makeLocatorQueries } from './locator-queries'; import { ariaSnapshot as _ariaSnapshot } from './aria'; import { Mouse } from './mouse'; @@ -613,6 +614,7 @@ export const page = { return new Locator(() => { const doc = iframeDoc(); if (!doc) return []; + if (isXPath(selector)) return queryXPath(doc, resolveXPath(selector)); const parts = resolveSelector(selector); const seen = new Set(); const out: Element[] = []; diff --git a/src/browser/locator-utils.ts b/src/browser/locator-utils.ts index 7be9084..a9541fa 100644 --- a/src/browser/locator-utils.ts +++ b/src/browser/locator-utils.ts @@ -8,3 +8,24 @@ export function textMatches(el: Element, text: string | RegExp, exact = false): export function resolveSelector(selector: string): string[] { return selector.split(',').map(s => s.trim()); } + +export function isXPath(selector: string): boolean { + const s = selector.trimStart(); + return s.startsWith('//') || s.startsWith('xpath='); +} + +export function resolveXPath(selector: string): string { + const s = selector.trimStart(); + return s.startsWith('xpath=') ? s.slice('xpath='.length) : s; +} + +export function queryXPath(context: Document | Element, xpath: string): Element[] { + const doc = context.nodeType === Node.DOCUMENT_NODE ? context as Document : context.ownerDocument!; + const result = doc.evaluate(xpath, context, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + const out: Element[] = []; + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i); + if (node && node.nodeType === 1) out.push(node as Element); + } + return out; +} diff --git a/src/browser/locator.ts b/src/browser/locator.ts index 5963803..678eb03 100644 --- a/src/browser/locator.ts +++ b/src/browser/locator.ts @@ -1,7 +1,7 @@ import { _awaitOrAbort, iframeDoc, iframeWin, _withCommand } from './browser'; import { actionTimeout } from './config'; export { textMatches, resolveSelector } from './locator-utils'; -import { textMatches, resolveSelector } from './locator-utils'; +import { textMatches, resolveSelector, isXPath, resolveXPath, queryXPath } from './locator-utils'; import { makeLocatorQueries } from './locator-queries'; import { ariaSnapshot } from './aria'; @@ -173,9 +173,17 @@ export class Locator { } locator(selector: string): Locator { return new Locator(() => { - const parts = resolveSelector(selector); const seen = new Set(); const out: Element[] = []; + if (isXPath(selector)) { + for (const root of this._els()) { + for (const el of queryXPath(root, resolveXPath(selector))) { + if (!seen.has(el)) { seen.add(el); out.push(el); } + } + } + return out; + } + const parts = resolveSelector(selector); for (const root of this._els()) { for (const base of parts) { for (const el of Array.from(root.querySelectorAll(base))) { @@ -522,6 +530,7 @@ export class FrameLocator { return new Locator(() => { const doc = this._frameDoc(); if (!doc) return []; + if (isXPath(selector)) return queryXPath(doc, resolveXPath(selector)); const parts = resolveSelector(selector); const seen = new Set(); const out: Element[] = []; diff --git a/test/specs/apiCoverage.spec.ts b/test/specs/apiCoverage.spec.ts index f97a567..f34923b 100644 --- a/test/specs/apiCoverage.spec.ts +++ b/test/specs/apiCoverage.spec.ts @@ -1169,3 +1169,50 @@ test.describe('Download – full API', () => { }); }); +// ── XPath locators ───────────────────────────────────────────────────────────── + +test.describe('XPath locators', () => { + test.beforeEach(async ({ page, node }) => { await loadTestPage({ page, node }); }); + + test('// prefix finds element by tag and id attribute', async ({ page }) => { + await expect(page.locator(`//button[@id='clickBtn']`)).toBeVisible(); + }); + + test('xpath= prefix finds element', async ({ page }) => { + await expect(page.locator(`xpath=//button[@id='clickBtn']`)).toBeVisible(); + }); + + test('XPath text() predicate matches element text', async ({ page }) => { + await expect(page.locator(`//button[text()='Click']`)).toBeVisible(); + }); + + test('XPath contains() matches partial attribute value', async ({ page }) => { + await expect(page.locator(`//input[contains(@placeholder,'here')]`)).toBeVisible(); + }); + + test('XPath click triggers action', async ({ page }) => { + await page.locator(`//button[@id='clickBtn']`).click(); + await expect(page.locator('#mouseResult')).toHaveText('Clicked'); + }); + + test('XPath fill types into input', async ({ page }) => { + await page.locator(`//input[@id='textInput']`).fill('xpath fill'); + await expect(page.locator('#textInput')).toHaveValue('xpath fill'); + }); + + test('XPath count returns number of matched elements', async ({ page }) => { + const count = await page.locator(`//section[@class='card']`).count(); + expect(count).toBeGreaterThan(0); + }); + + test('chained .locator() with XPath narrows within CSS root', async ({ page }) => { + const heading = page.locator('.card').locator(`//h2[text()='Mouse / Pointer']`); + await expect(heading).toBeVisible(); + }); + + test('chained CSS then xpath= prefix form', async ({ page }) => { + const btn = page.locator('.card').locator(`xpath=//button[@id='dblClickBtn']`); + await expect(btn).toBeVisible(); + }); +}); + diff --git a/test/unit/locator.test.ts b/test/unit/locator.test.ts index 22730af..0051866 100644 --- a/test/unit/locator.test.ts +++ b/test/unit/locator.test.ts @@ -1,6 +1,6 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { textMatches, resolveSelector } from '../../src/browser/locator-utils'; +import { textMatches, resolveSelector, isXPath, resolveXPath } from '../../src/browser/locator-utils'; // ── textMatches ─────────────────────────────────────────────────────────────── @@ -57,3 +57,43 @@ describe('resolveSelector', () => { assert.deepEqual(resolveSelector(' span , div '), ['span', 'div']); }); }); + +// ── isXPath ─────────────────────────────────────────────────────────────────── + +describe('isXPath', () => { + test('detects // prefix', () => { + assert.ok(isXPath('//div')); + }); + + test('detects xpath= prefix', () => { + assert.ok(isXPath('xpath=//div')); + }); + + test('returns false for CSS selector', () => { + assert.ok(!isXPath('#id')); + assert.ok(!isXPath('.class')); + assert.ok(!isXPath('button')); + }); + + test('handles leading whitespace', () => { + assert.ok(isXPath(' //div')); + assert.ok(isXPath(' xpath=//div')); + }); +}); + +// ── resolveXPath ────────────────────────────────────────────────────────────── + +describe('resolveXPath', () => { + test('strips xpath= prefix', () => { + assert.equal(resolveXPath('xpath=//div'), '//div'); + }); + + test('returns // expression unchanged', () => { + assert.equal(resolveXPath('//div[@id="foo"]'), '//div[@id="foo"]'); + }); + + test('handles leading whitespace', () => { + assert.equal(resolveXPath(' xpath=//span'), '//span'); + assert.equal(resolveXPath(' //span'), '//span'); + }); +});