Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Element>();
const out: Element[] = [];
Expand Down
21 changes: 21 additions & 0 deletions src/browser/locator-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 11 additions & 2 deletions src/browser/locator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -173,9 +173,17 @@ export class Locator {
}
locator(selector: string): Locator {
return new Locator(() => {
const parts = resolveSelector(selector);
const seen = new Set<Element>();
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))) {
Expand Down Expand Up @@ -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<Element>();
const out: Element[] = [];
Expand Down
47 changes: 47 additions & 0 deletions test/specs/apiCoverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

42 changes: 41 additions & 1 deletion test/unit/locator.test.ts
Original file line number Diff line number Diff line change
@@ -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 ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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');
});
});
Loading