From a370ba5760afa72d6adbcabb882aee98b67c2477 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sat, 18 Apr 2026 00:23:30 +0300 Subject: [PATCH] feat(locator): add withClass, negation, and raw-predicate helpers to builder DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds withClass (variadic, word-exact), withoutClass, withoutText, withoutAttr, withoutChild, withoutDescendant, and raw and()/andNot() escape hatches. Lets users express complex XPath like `not(.//svg)` and multi-class matches through the fluent builder instead of writing raw XPath. Also documents previously undocumented methods (or, withAttrStartsWith/EndsWith/Contains, as). withClassAttr is kept for backward compatibility and marked @deprecated in favor of withClass (word-exact) or withAttrContains('class', …) for substring matching. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/locators.md | 151 +++++++++++++++++++++++++++++++++++++- lib/locator.js | 112 ++++++++++++++++++++++++++++ test/unit/locator_test.js | 61 +++++++++++++++ 3 files changed, 320 insertions(+), 4 deletions(-) diff --git a/docs/locators.md b/docs/locators.md index bc0ca99ee..c798e1566 100644 --- a/docs/locators.md +++ b/docs/locators.md @@ -143,7 +143,7 @@ locate('//table') // will be printed as 'edit button' ``` -`locate` has following methods: +`locate` has following methods. The `with*` family filters elements positively; `without*` excludes; `and` / `andNot` / `or` let you compose raw predicates or union locators. #### find @@ -165,13 +165,61 @@ Find an element with provided attributes locate('input').withAttr({ placeholder: 'Type in name' }); ``` +#### withAttrStartsWith + +Find an element whose attribute value starts with a given text: + +```js +// find links to https:// URLs +locate('a').withAttrStartsWith('href', 'https://'); +``` + +#### withAttrEndsWith + +Find an element whose attribute value ends with a given text: + +```js +locate('a').withAttrEndsWith('href', '.pdf'); +``` + +#### withAttrContains + +Find an element whose attribute value contains a given text: + +```js +locate('a').withAttrContains('href', 'google'); +``` + +#### withClass + +Find an element with all of the provided CSS classes. Variadic — pass any number of class names; all must be present. Uses word-exact matching (same semantics as CSS `.foo`). + +```js +// find button that has ALL of these classes +locate('button').withClass('btn-primary', 'btn-lg', 'btn-selected'); +``` + +> ℹ Prefer `withClass` over `withClassAttr`. `withClassAttr` uses substring matching on `@class` (so `'btn'` matches `'btn-lg'`), which rarely does what you want. + +#### withoutClass + +Find an element that does NOT carry any of the provided CSS classes: + +```js +// rows not marked as deleted +locate('tr').withoutClass('deleted'); + +// combine with withClass to express "has X but not Y" +locate('a').withClass('ps-menu-button').withoutClass('active'); +``` + #### withClassAttr -Find an element with class attribute +Legacy alias — uses substring matching on `@class`. Prefer `withClass` (word-exact) or `withAttrContains('class', …)` if substring matching is intended. ```js -// find div with class contains 'form' -locate('div').withClassAttr('text'); +// matches elements whose @class CONTAINS 'form-' (e.g. 'form-wrapper', 'form-field') +locate('div').withClassAttr('form-'); ``` #### withChild @@ -208,6 +256,40 @@ Find an element with exact text locate('button').withTextEquals('Add'); ``` +#### withoutText + +Find an element that does NOT contain the given text: + +```js +locate('li').withoutText('Archived'); +``` + +#### withoutAttr + +Find an element that does NOT have any of the given attribute/value pairs: + +```js +// buttons that are not disabled +locate('button').withoutAttr({ disabled: '' }); +``` + +#### withoutChild + +Find an element with no direct child matching the provided locator: + +```js +locate('form').withoutChild('input[type=submit]'); +``` + +#### withoutDescendant + +Find an element with no descendant matching the provided locator. Covers the common "not `.//svg`" case: + +```js +// buttons without an icon (svg) inside +locate('button').withoutDescendant('svg'); +``` + #### first Get first element: @@ -264,6 +346,67 @@ Finds element located after the provided one locate('button').after('.btn-cancel'); ``` +#### or + +Composes two locators: matches elements that satisfy either one (XPath union `|`): + +```js +// primary or secondary submit button +locate('button.submit').or('input[type=submit]'); +``` + +#### and + +Escape hatch: appends a raw XPath predicate `[expr]`. The argument is inserted as-is — quoting and escaping are your responsibility. + +```js +locate('input').and('@type="text" or @type="email"'); +``` + +#### andNot + +Escape hatch for negation: appends `[not(expr)]`: + +```js +// button that has no svg anywhere inside +locate('button').andNot('.//svg'); + +// element without the hidden attribute +locate('div').andNot('@hidden'); +``` + +#### as + +Give the locator a short descriptive name used in logs and reports (does not change matching): + +```js +locate('//table').find('a').withText('Edit').as('edit button'); +// logged as 'edit button' instead of a long XPath +``` + +### Translating complex XPath + +Long XPath expressions become readable with the DSL. For example: + +``` +//*[self::button + and contains(@class,"red-btn") + and contains(@class,"btn-text-and-icon") + and contains(@class,"btn-lg") + and contains(@class,"btn-selected") + and normalize-space(.)="Button selected" + and not(.//svg)] +``` + +becomes: + +```js +locate('button') + .withClass('red-btn', 'btn-text-and-icon', 'btn-lg', 'btn-selected') + .withText('Button selected') + .withoutDescendant('svg'); +``` + ## ID Locators ID locators are best to select the exact semantic element in web and mobile testing: diff --git a/lib/locator.js b/lib/locator.js index 5ddb3bd7d..a2f0ea0a3 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -381,9 +381,121 @@ class Locator { return new Locator({ xpath }) } + /** + * Find an element with all of the provided CSS classes (word-exact match). + * Accepts variadic class names; all must be present. + * + * Example: + * locate('button').withClass('btn-primary', 'btn-lg') + * + * @param {...string} classes + * @returns {Locator} + */ + withClass(...classes) { + if (!classes.length) return this + const predicates = classes.map(c => `contains(concat(' ', normalize-space(@class), ' '), ' ${c} ')`) + const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and ')) + return new Locator({ xpath }) + } + + /** + * Find an element with none of the provided CSS classes. + * + * Example: + * locate('tr').withoutClass('deleted') + * + * @param {...string} classes + * @returns {Locator} + */ + withoutClass(...classes) { + if (!classes.length) return this + const predicates = classes.map(c => `not(contains(concat(' ', normalize-space(@class), ' '), ' ${c} '))`) + const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and ')) + return new Locator({ xpath }) + } + + /** + * Find an element that does NOT contain the provided text. + * @param {string} text + * @returns {Locator} + */ + withoutText(text) { + text = xpathLocator.literal(text) + const xpath = sprintf('%s[%s]', this.toXPath(), `not(contains(., ${text}))`) + return new Locator({ xpath }) + } + + /** + * Find an element that does NOT have any of the provided attribute/value pairs. + * @param {Object.} attributes + * @returns {Locator} + */ + withoutAttr(attributes) { + const operands = [] + for (const attr of Object.keys(attributes)) { + operands.push(`not(@${attr} = ${xpathLocator.literal(attributes[attr])})`) + } + const xpath = sprintf('%s[%s]', this.toXPath(), operands.join(' and ')) + return new Locator({ xpath }) + } + + /** + * Find an element that has no direct child matching the provided locator. + * @param {CodeceptJS.LocatorOrString} locator + * @returns {Locator} + */ + withoutChild(locator) { + const xpath = sprintf('%s[not(./child::%s)]', this.toXPath(), convertToSubSelector(locator)) + return new Locator({ xpath }) + } + + /** + * Find an element that has no descendant matching the provided locator. + * + * Example: + * locate('button').withoutDescendant('svg') + * + * @param {CodeceptJS.LocatorOrString} locator + * @returns {Locator} + */ + withoutDescendant(locator) { + const xpath = sprintf('%s[not(./descendant::%s)]', this.toXPath(), convertToSubSelector(locator)) + return new Locator({ xpath }) + } + + /** + * Append a raw XPath predicate. Escape hatch for expressions not covered by the DSL. + * Argument is inserted as-is inside `[ ]`; quoting/escaping is the caller's responsibility. + * + * Example: + * locate('input').and('@type="text" or @type="email"') + * + * @param {string} xpathExpression + * @returns {Locator} + */ + and(xpathExpression) { + const xpath = sprintf('%s[%s]', this.toXPath(), xpathExpression) + return new Locator({ xpath }) + } + + /** + * Append a negated raw XPath predicate: `[not(expr)]`. + * + * Example: + * locate('button').andNot('.//svg') // button without a descendant svg + * + * @param {string} xpathExpression + * @returns {Locator} + */ + andNot(xpathExpression) { + const xpath = sprintf('%s[not(%s)]', this.toXPath(), xpathExpression) + return new Locator({ xpath }) + } + /** * @param {String} text * @returns {Locator} + * @deprecated Use {@link Locator#withClass} for word-exact class matching, or {@link Locator#withAttrContains} for substring matching. */ withClassAttr(text) { const xpath = sprintf('%s[%s]', this.toXPath(), `contains(@class, '${text}')`) diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index 070bf8826..47298465a 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -407,6 +407,67 @@ describe('Locator', () => { expect(nodes).to.have.length(9) }) + it('withClass: single class (word-exact)', () => { + const l = Locator.build('a').withClass('ps-menu-button') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(10, l.toXPath()) + }) + + it('withClass: variadic ANDs class conditions', () => { + const l = Locator.build('a').withClass('ps-menu-button', 'active') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(1, l.toXPath()) + }) + + it('withClass: word-exact (does not match partial class)', () => { + const l = Locator.build('div').withClass('form-') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(0, l.toXPath()) + }) + + it('withoutClass: excludes elements carrying the class', () => { + const l = Locator.build('a').withClass('ps-menu-button').withoutClass('active') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(9, l.toXPath()) + }) + + it('withoutText: excludes elements containing text', () => { + const l = Locator.build('span').withoutText('Hey') + const nodes = xpath.select(l.toXPath(), doc) + const matchesHey = nodes.find(n => n.firstChild && n.firstChild.data === 'Hey boy') + expect(matchesHey).to.be.undefined + }) + + it('withoutAttr: excludes matching attribute value', () => { + const l = Locator.build('input').withoutAttr({ type: 'hidden' }) + const nodes = xpath.select(l.toXPath(), doc) + nodes.forEach(n => expect(n.getAttribute('type')).to.not.equal('hidden')) + }) + + it('withoutDescendant: excludes elements with a descendant match', () => { + const l = Locator.build('a').withClass('ps-menu-button').withoutDescendant('.ps-submenu-expand-icon') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(1, l.toXPath()) + }) + + it('withoutChild: excludes elements with a direct child match', () => { + const l = Locator.build('p').withoutChild('span') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(0, l.toXPath()) + }) + + it('and: appends raw xpath predicate', () => { + const l = Locator.build('input').and('@type="checkbox"') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(1, l.toXPath()) + }) + + it('andNot: wraps raw xpath predicate in not()', () => { + const l = Locator.build('a').withClass('ps-menu-button').andNot('.//span[contains(@class, "ps-submenu-expand-icon")]') + const nodes = xpath.select(l.toXPath(), doc) + expect(nodes).to.have.length(1, l.toXPath()) + }) + it('should build locator to match element containing a text', () => { const l = Locator.build('span').withText('Hey') const nodes = xpath.select(l.toXPath(), doc)