Skip to content

Commit c61c2e5

Browse files
miraoclaude
andauthored
fix: make XPath relative in buildLocatorString for within() scope (#5474)
* fix: make XPath relative in buildLocatorString for within() scope (#5473) Playwright's XPath engine auto-converts "//..." to ".//..." when searching within an element, but only when the selector starts with "/". Locator methods like at(), first(), last() wrap XPath in parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion and causing XPath to search from the document root instead of the within() scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add acceptance test for locate().at().find() inside within() (#5473) Adds a Playwright acceptance test that verifies XPath from locate().at().find() is correctly scoped when used inside within(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aec3876 commit c61c2e5

File tree

4 files changed

+78
-2
lines changed

4 files changed

+78
-2
lines changed

lib/helper/Playwright.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4157,9 +4157,15 @@ class Playwright extends Helper {
41574157

41584158
export default Playwright
41594159

4160-
function buildLocatorString(locator) {
4160+
export function buildLocatorString(locator) {
41614161
if (locator.isXPath()) {
4162-
return `xpath=${locator.value}`
4162+
// Make XPath relative so it works correctly within scoped contexts (e.g. within()).
4163+
// Playwright's XPath engine auto-converts "//..." to ".//..." when the root is not a Document,
4164+
// but only when the selector starts with "/". Locator methods like at() wrap XPath in
4165+
// parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion.
4166+
// We fix this by prepending "." before the first "//" that follows any leading parentheses.
4167+
const value = locator.value.replace(/^(\(*)\/\//, '$1.//')
4168+
return `xpath=${value}`
41634169
}
41644170
if (locator.isShadow()) {
41654171
// Convert shadow locator to CSS with >> chaining operator

test/acceptance/within_test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
Feature('within', { retries: 3 })
22

3+
Scenario('within with locate().at().find() should scope XPath @Playwright', async ({ I }) => {
4+
I.amOnPage('/form/bug5473')
5+
await within('#list2', async () => {
6+
await I.see('Second', locate('.item').at(1).find('.label'))
7+
})
8+
})
9+
310
Scenario('within on form @WebDriverIO @Puppeteer @Playwright', async ({ I }) => {
411
I.amOnPage('/form/bug1467')
512
I.see('TEST TEST')
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html>
3+
<head><title>within + locate().at() bug</title></head>
4+
<body>
5+
<div id="outer">
6+
<ul id="list1">
7+
<li class="item"><span class="label">First</span></li>
8+
</ul>
9+
<ul id="list2">
10+
<li class="item"><span class="label">Second</span></li>
11+
</ul>
12+
</div>
13+
</body>
14+
</html>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect } from 'chai'
2+
import Locator from '../../../lib/locator.js'
3+
import { buildLocatorString } from '../../../lib/helper/Playwright.js'
4+
5+
describe('buildLocatorString', () => {
6+
it('should make plain XPath relative', () => {
7+
const locator = new Locator({ xpath: '//div' })
8+
expect(buildLocatorString(locator)).to.equal('xpath=.//div')
9+
})
10+
11+
it('should make XPath with parentheses (from at()) relative', () => {
12+
const locator = new Locator('.item').at(1)
13+
const result = buildLocatorString(locator)
14+
expect(result).to.match(/^xpath=\(\.\/\//)
15+
})
16+
17+
it('should make XPath from at().find() relative', () => {
18+
const locator = new Locator('.item').at(1).find('.label')
19+
const result = buildLocatorString(locator)
20+
expect(result).to.match(/^xpath=\(\.\/\//)
21+
})
22+
23+
it('should make XPath from first() relative', () => {
24+
const locator = new Locator('.item').first()
25+
const result = buildLocatorString(locator)
26+
expect(result).to.match(/^xpath=\(\.\/\//)
27+
})
28+
29+
it('should make XPath from last() relative', () => {
30+
const locator = new Locator('.item').last()
31+
const result = buildLocatorString(locator)
32+
expect(result).to.match(/^xpath=\(\.\/\//)
33+
})
34+
35+
it('should not double-prefix already relative XPath', () => {
36+
const locator = new Locator({ xpath: './/div' })
37+
expect(buildLocatorString(locator)).to.equal('xpath=.//div')
38+
})
39+
40+
it('should handle XPath that was already relative inside parentheses', () => {
41+
const locator = new Locator({ xpath: '(.//div)[1]' })
42+
expect(buildLocatorString(locator)).to.equal('xpath=(.//div)[1]')
43+
})
44+
45+
it('should return CSS locators unchanged', () => {
46+
const locator = new Locator('.my-class')
47+
expect(buildLocatorString(locator)).to.equal('.my-class')
48+
})
49+
})

0 commit comments

Comments
 (0)