From 03972041259bc14169de61014553b60bda096150 Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Thu, 5 Mar 2026 13:12:45 +0100 Subject: [PATCH 1/2] 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 --- lib/helper/Playwright.js | 10 +++- .../Playwright_buildLocatorString_test.js | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 test/unit/helper/Playwright_buildLocatorString_test.js diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 9b032d56a..0d96b7240 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -4134,9 +4134,15 @@ class Playwright extends Helper { export default Playwright -function buildLocatorString(locator) { +export function buildLocatorString(locator) { if (locator.isXPath()) { - return `xpath=${locator.value}` + // Make XPath relative so it works correctly within scoped contexts (e.g. within()). + // Playwright's XPath engine auto-converts "//..." to ".//..." when the root is not a Document, + // but only when the selector starts with "/". Locator methods like at() wrap XPath in + // parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion. + // We fix this by prepending "." before the first "//" that follows any leading parentheses. + const value = locator.value.replace(/^(\(*)\/\//, '$1.//') + return `xpath=${value}` } if (locator.isShadow()) { // Convert shadow locator to CSS with >> chaining operator diff --git a/test/unit/helper/Playwright_buildLocatorString_test.js b/test/unit/helper/Playwright_buildLocatorString_test.js new file mode 100644 index 000000000..f9957a5f7 --- /dev/null +++ b/test/unit/helper/Playwright_buildLocatorString_test.js @@ -0,0 +1,49 @@ +import { expect } from 'chai' +import Locator from '../../../lib/locator.js' +import { buildLocatorString } from '../../../lib/helper/Playwright.js' + +describe('buildLocatorString', () => { + it('should make plain XPath relative', () => { + const locator = new Locator({ xpath: '//div' }) + expect(buildLocatorString(locator)).to.equal('xpath=.//div') + }) + + it('should make XPath with parentheses (from at()) relative', () => { + const locator = new Locator('.item').at(1) + const result = buildLocatorString(locator) + expect(result).to.match(/^xpath=\(\.\/\//) + }) + + it('should make XPath from at().find() relative', () => { + const locator = new Locator('.item').at(1).find('.label') + const result = buildLocatorString(locator) + expect(result).to.match(/^xpath=\(\.\/\//) + }) + + it('should make XPath from first() relative', () => { + const locator = new Locator('.item').first() + const result = buildLocatorString(locator) + expect(result).to.match(/^xpath=\(\.\/\//) + }) + + it('should make XPath from last() relative', () => { + const locator = new Locator('.item').last() + const result = buildLocatorString(locator) + expect(result).to.match(/^xpath=\(\.\/\//) + }) + + it('should not double-prefix already relative XPath', () => { + const locator = new Locator({ xpath: './/div' }) + expect(buildLocatorString(locator)).to.equal('xpath=.//div') + }) + + it('should handle XPath that was already relative inside parentheses', () => { + const locator = new Locator({ xpath: '(.//div)[1]' }) + expect(buildLocatorString(locator)).to.equal('xpath=(.//div)[1]') + }) + + it('should return CSS locators unchanged', () => { + const locator = new Locator('.my-class') + expect(buildLocatorString(locator)).to.equal('.my-class') + }) +}) From 9a63b24a4bc51c680c270121ebfbc6f39de83973 Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Fri, 6 Mar 2026 08:21:44 +0100 Subject: [PATCH 2/2] 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 --- test/acceptance/within_test.js | 7 +++++++ test/data/app/view/form/bug5473.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 test/data/app/view/form/bug5473.php diff --git a/test/acceptance/within_test.js b/test/acceptance/within_test.js index b0b75a3bd..1d98321d6 100644 --- a/test/acceptance/within_test.js +++ b/test/acceptance/within_test.js @@ -1,5 +1,12 @@ Feature('within', { retries: 3 }) +Scenario('within with locate().at().find() should scope XPath @Playwright', async ({ I }) => { + I.amOnPage('/form/bug5473') + await within('#list2', async () => { + await I.see('Second', locate('.item').at(1).find('.label')) + }) +}) + Scenario('within on form @WebDriverIO @Puppeteer @Playwright', async ({ I }) => { I.amOnPage('/form/bug1467') I.see('TEST TEST') diff --git a/test/data/app/view/form/bug5473.php b/test/data/app/view/form/bug5473.php new file mode 100644 index 000000000..a0fa4ad15 --- /dev/null +++ b/test/data/app/view/form/bug5473.php @@ -0,0 +1,14 @@ + + + within + locate().at() bug + +
+
    +
  • First
  • +
+
    +
  • Second
  • +
+
+ +