-
-
Notifications
You must be signed in to change notification settings - Fork 754
Description
The report was created for me by Claude Sonnet 4.6.
What are you trying to achieve?
Use locate().at().find() inside within() to find elements scoped to the within() context.
What do you get instead?
The XPath generated by at() and find() bypasses the within() scope and searches from the document root. Elements from outside the within() context are matched instead.
Minimal reproduction
codecept.conf.js:
exports.config = {
tests: './*_test.js',
output: './output',
helpers: {
Playwright: {
browser: 'chromium',
url: 'http://localhost',
show: false,
},
},
};within_test.js:
Feature('within + locate().at().find()');
Scenario('locate().at().find() should work inside within()', async ({ I }) => {
I.amOnPage('data:text/html,<div id="outer"><ul id="list1"><li class="item"><span class="label">First</span></li></ul><ul id="list2"><li class="item"><span class="label">Second</span></li></ul></div>');
// This works - plain CSS inside within()
within('#list2', () => {
I.see('Second', '.label');
});
// This FAILS - locate().at().find() inside within()
// It finds "First" from #list1 instead of "Second" from #list2,
// because the XPath escapes the within() scope
within('#list2', () => {
I.see('Second', locate('.item').at(1).find('.label'));
});
});Actual output (FAILS):
expected element {xpath: (//*[...item...])[position()=1]//*[...label...]}
to include "Second"
- First
+ Second
The first within() block (plain CSS) passes. The second one (with locate().at().find()) fails because the XPath searches from the document root, finding "First" from #list1 instead of "Second" from #list2.
Root cause
The issue is in the interaction between buildLocatorString() in lib/helper/Playwright.js and Playwright's XPath engine.
Playwright's XPath engine (in injectedScriptSource.js) auto-converts absolute XPath to relative when the root is not a Document:
// From Playwright's XPathEngine.queryAll:
if (selector.startsWith("/") && root.nodeType !== Node.DOCUMENT_NODE)
selector = "." + selector;This converts //div → .//div when searching within an element. But it only triggers when the selector starts with /.
CodeceptJS's Locator.at() (in lib/locator.js) wraps the XPath in parentheses:
at(position) {
const xpath = sprintf('(%s)[position()=%s]', this.toXPath(), xpathPosition);
return new Locator({ xpath });
}This produces (//*[...])[position()=1] which starts with (, not /, so Playwright's auto-conversion never triggers. The // inside the parentheses then searches from the document root via document.evaluate(), completely ignoring the within() scope.
Suggested fix
In buildLocatorString() in lib/helper/Playwright.js, make XPath relative before passing it to Playwright:
function buildLocatorString(locator) {
if (locator.isCustom()) {
return buildCustomLocatorString(locator)
}
if (locator.isXPath()) {
// 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}`
}
return locator.simplify()
}This is safe because .// is equivalent to // when evaluated from the document root, but correctly scopes to descendants when evaluated from an element.
Affected methods
Any Locator DSL method that wraps XPath in parentheses before the leading //:
locate().at()→(//...)[position()=N]locate().at().find()→(//...)[position()=N]//...locate().first()/locate().last()(which callat())
Details
- CodeceptJS version: 3.7.6
- NodeJS version: 20.x
- Helper: Playwright
- Browser: Chromium (likely affects all browsers since it's an XPath semantics issue)