Skip to content

locate().at().find() doesn't work inside within() with Playwright helper #5473

@mirao

Description

@mirao

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 call at())

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions