Skip to content

Commit a370ba5

Browse files
DavertMikclaude
andcommitted
feat(locator): add withClass, negation, and raw-predicate helpers to builder DSL
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) <noreply@anthropic.com>
1 parent 909c877 commit a370ba5

3 files changed

Lines changed: 320 additions & 4 deletions

File tree

docs/locators.md

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ locate('//table')
143143
// will be printed as 'edit button'
144144
```
145145

146-
`locate` has following methods:
146+
`locate` has following methods. The `with*` family filters elements positively; `without*` excludes; `and` / `andNot` / `or` let you compose raw predicates or union locators.
147147

148148
#### find
149149

@@ -165,13 +165,61 @@ Find an element with provided attributes
165165
locate('input').withAttr({ placeholder: 'Type in name' });
166166
```
167167

168+
#### withAttrStartsWith
169+
170+
Find an element whose attribute value starts with a given text:
171+
172+
```js
173+
// find links to https:// URLs
174+
locate('a').withAttrStartsWith('href', 'https://');
175+
```
176+
177+
#### withAttrEndsWith
178+
179+
Find an element whose attribute value ends with a given text:
180+
181+
```js
182+
locate('a').withAttrEndsWith('href', '.pdf');
183+
```
184+
185+
#### withAttrContains
186+
187+
Find an element whose attribute value contains a given text:
188+
189+
```js
190+
locate('a').withAttrContains('href', 'google');
191+
```
192+
193+
#### withClass
194+
195+
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`).
196+
197+
```js
198+
// find button that has ALL of these classes
199+
locate('button').withClass('btn-primary', 'btn-lg', 'btn-selected');
200+
```
201+
202+
> ℹ Prefer `withClass` over `withClassAttr`. `withClassAttr` uses substring matching on `@class` (so `'btn'` matches `'btn-lg'`), which rarely does what you want.
203+
204+
#### withoutClass
205+
206+
Find an element that does NOT carry any of the provided CSS classes:
207+
208+
```js
209+
// rows not marked as deleted
210+
locate('tr').withoutClass('deleted');
211+
212+
// combine with withClass to express "has X but not Y"
213+
locate('a').withClass('ps-menu-button').withoutClass('active');
214+
```
215+
168216
#### withClassAttr
169217

170-
Find an element with class attribute
218+
Legacy alias — uses substring matching on `@class`. Prefer `withClass` (word-exact) or `withAttrContains('class', …)` if substring matching is intended.
171219

172220
```js
173-
// find div with class contains 'form'
174-
locate('div').withClassAttr('text');
221+
// matches elements whose @class CONTAINS 'form-' (e.g. 'form-wrapper', 'form-field')
222+
locate('div').withClassAttr('form-');
175223
```
176224

177225
#### withChild
@@ -208,6 +256,40 @@ Find an element with exact text
208256
locate('button').withTextEquals('Add');
209257
```
210258

259+
#### withoutText
260+
261+
Find an element that does NOT contain the given text:
262+
263+
```js
264+
locate('li').withoutText('Archived');
265+
```
266+
267+
#### withoutAttr
268+
269+
Find an element that does NOT have any of the given attribute/value pairs:
270+
271+
```js
272+
// buttons that are not disabled
273+
locate('button').withoutAttr({ disabled: '' });
274+
```
275+
276+
#### withoutChild
277+
278+
Find an element with no direct child matching the provided locator:
279+
280+
```js
281+
locate('form').withoutChild('input[type=submit]');
282+
```
283+
284+
#### withoutDescendant
285+
286+
Find an element with no descendant matching the provided locator. Covers the common "not `.//svg`" case:
287+
288+
```js
289+
// buttons without an icon (svg) inside
290+
locate('button').withoutDescendant('svg');
291+
```
292+
211293
#### first
212294

213295
Get first element:
@@ -264,6 +346,67 @@ Finds element located after the provided one
264346
locate('button').after('.btn-cancel');
265347
```
266348

349+
#### or
350+
351+
Composes two locators: matches elements that satisfy either one (XPath union `|`):
352+
353+
```js
354+
// primary or secondary submit button
355+
locate('button.submit').or('input[type=submit]');
356+
```
357+
358+
#### and
359+
360+
Escape hatch: appends a raw XPath predicate `[expr]`. The argument is inserted as-is — quoting and escaping are your responsibility.
361+
362+
```js
363+
locate('input').and('@type="text" or @type="email"');
364+
```
365+
366+
#### andNot
367+
368+
Escape hatch for negation: appends `[not(expr)]`:
369+
370+
```js
371+
// button that has no svg anywhere inside
372+
locate('button').andNot('.//svg');
373+
374+
// element without the hidden attribute
375+
locate('div').andNot('@hidden');
376+
```
377+
378+
#### as
379+
380+
Give the locator a short descriptive name used in logs and reports (does not change matching):
381+
382+
```js
383+
locate('//table').find('a').withText('Edit').as('edit button');
384+
// logged as 'edit button' instead of a long XPath
385+
```
386+
387+
### Translating complex XPath
388+
389+
Long XPath expressions become readable with the DSL. For example:
390+
391+
```
392+
//*[self::button
393+
and contains(@class,"red-btn")
394+
and contains(@class,"btn-text-and-icon")
395+
and contains(@class,"btn-lg")
396+
and contains(@class,"btn-selected")
397+
and normalize-space(.)="Button selected"
398+
and not(.//svg)]
399+
```
400+
401+
becomes:
402+
403+
```js
404+
locate('button')
405+
.withClass('red-btn', 'btn-text-and-icon', 'btn-lg', 'btn-selected')
406+
.withText('Button selected')
407+
.withoutDescendant('svg');
408+
```
409+
267410
## ID Locators
268411

269412
ID locators are best to select the exact semantic element in web and mobile testing:

lib/locator.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,121 @@ class Locator {
381381
return new Locator({ xpath })
382382
}
383383

384+
/**
385+
* Find an element with all of the provided CSS classes (word-exact match).
386+
* Accepts variadic class names; all must be present.
387+
*
388+
* Example:
389+
* locate('button').withClass('btn-primary', 'btn-lg')
390+
*
391+
* @param {...string} classes
392+
* @returns {Locator}
393+
*/
394+
withClass(...classes) {
395+
if (!classes.length) return this
396+
const predicates = classes.map(c => `contains(concat(' ', normalize-space(@class), ' '), ' ${c} ')`)
397+
const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
398+
return new Locator({ xpath })
399+
}
400+
401+
/**
402+
* Find an element with none of the provided CSS classes.
403+
*
404+
* Example:
405+
* locate('tr').withoutClass('deleted')
406+
*
407+
* @param {...string} classes
408+
* @returns {Locator}
409+
*/
410+
withoutClass(...classes) {
411+
if (!classes.length) return this
412+
const predicates = classes.map(c => `not(contains(concat(' ', normalize-space(@class), ' '), ' ${c} '))`)
413+
const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
414+
return new Locator({ xpath })
415+
}
416+
417+
/**
418+
* Find an element that does NOT contain the provided text.
419+
* @param {string} text
420+
* @returns {Locator}
421+
*/
422+
withoutText(text) {
423+
text = xpathLocator.literal(text)
424+
const xpath = sprintf('%s[%s]', this.toXPath(), `not(contains(., ${text}))`)
425+
return new Locator({ xpath })
426+
}
427+
428+
/**
429+
* Find an element that does NOT have any of the provided attribute/value pairs.
430+
* @param {Object.<string, string>} attributes
431+
* @returns {Locator}
432+
*/
433+
withoutAttr(attributes) {
434+
const operands = []
435+
for (const attr of Object.keys(attributes)) {
436+
operands.push(`not(@${attr} = ${xpathLocator.literal(attributes[attr])})`)
437+
}
438+
const xpath = sprintf('%s[%s]', this.toXPath(), operands.join(' and '))
439+
return new Locator({ xpath })
440+
}
441+
442+
/**
443+
* Find an element that has no direct child matching the provided locator.
444+
* @param {CodeceptJS.LocatorOrString} locator
445+
* @returns {Locator}
446+
*/
447+
withoutChild(locator) {
448+
const xpath = sprintf('%s[not(./child::%s)]', this.toXPath(), convertToSubSelector(locator))
449+
return new Locator({ xpath })
450+
}
451+
452+
/**
453+
* Find an element that has no descendant matching the provided locator.
454+
*
455+
* Example:
456+
* locate('button').withoutDescendant('svg')
457+
*
458+
* @param {CodeceptJS.LocatorOrString} locator
459+
* @returns {Locator}
460+
*/
461+
withoutDescendant(locator) {
462+
const xpath = sprintf('%s[not(./descendant::%s)]', this.toXPath(), convertToSubSelector(locator))
463+
return new Locator({ xpath })
464+
}
465+
466+
/**
467+
* Append a raw XPath predicate. Escape hatch for expressions not covered by the DSL.
468+
* Argument is inserted as-is inside `[ ]`; quoting/escaping is the caller's responsibility.
469+
*
470+
* Example:
471+
* locate('input').and('@type="text" or @type="email"')
472+
*
473+
* @param {string} xpathExpression
474+
* @returns {Locator}
475+
*/
476+
and(xpathExpression) {
477+
const xpath = sprintf('%s[%s]', this.toXPath(), xpathExpression)
478+
return new Locator({ xpath })
479+
}
480+
481+
/**
482+
* Append a negated raw XPath predicate: `[not(expr)]`.
483+
*
484+
* Example:
485+
* locate('button').andNot('.//svg') // button without a descendant svg
486+
*
487+
* @param {string} xpathExpression
488+
* @returns {Locator}
489+
*/
490+
andNot(xpathExpression) {
491+
const xpath = sprintf('%s[not(%s)]', this.toXPath(), xpathExpression)
492+
return new Locator({ xpath })
493+
}
494+
384495
/**
385496
* @param {String} text
386497
* @returns {Locator}
498+
* @deprecated Use {@link Locator#withClass} for word-exact class matching, or {@link Locator#withAttrContains} for substring matching.
387499
*/
388500
withClassAttr(text) {
389501
const xpath = sprintf('%s[%s]', this.toXPath(), `contains(@class, '${text}')`)

test/unit/locator_test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,67 @@ describe('Locator', () => {
407407
expect(nodes).to.have.length(9)
408408
})
409409

410+
it('withClass: single class (word-exact)', () => {
411+
const l = Locator.build('a').withClass('ps-menu-button')
412+
const nodes = xpath.select(l.toXPath(), doc)
413+
expect(nodes).to.have.length(10, l.toXPath())
414+
})
415+
416+
it('withClass: variadic ANDs class conditions', () => {
417+
const l = Locator.build('a').withClass('ps-menu-button', 'active')
418+
const nodes = xpath.select(l.toXPath(), doc)
419+
expect(nodes).to.have.length(1, l.toXPath())
420+
})
421+
422+
it('withClass: word-exact (does not match partial class)', () => {
423+
const l = Locator.build('div').withClass('form-')
424+
const nodes = xpath.select(l.toXPath(), doc)
425+
expect(nodes).to.have.length(0, l.toXPath())
426+
})
427+
428+
it('withoutClass: excludes elements carrying the class', () => {
429+
const l = Locator.build('a').withClass('ps-menu-button').withoutClass('active')
430+
const nodes = xpath.select(l.toXPath(), doc)
431+
expect(nodes).to.have.length(9, l.toXPath())
432+
})
433+
434+
it('withoutText: excludes elements containing text', () => {
435+
const l = Locator.build('span').withoutText('Hey')
436+
const nodes = xpath.select(l.toXPath(), doc)
437+
const matchesHey = nodes.find(n => n.firstChild && n.firstChild.data === 'Hey boy')
438+
expect(matchesHey).to.be.undefined
439+
})
440+
441+
it('withoutAttr: excludes matching attribute value', () => {
442+
const l = Locator.build('input').withoutAttr({ type: 'hidden' })
443+
const nodes = xpath.select(l.toXPath(), doc)
444+
nodes.forEach(n => expect(n.getAttribute('type')).to.not.equal('hidden'))
445+
})
446+
447+
it('withoutDescendant: excludes elements with a descendant match', () => {
448+
const l = Locator.build('a').withClass('ps-menu-button').withoutDescendant('.ps-submenu-expand-icon')
449+
const nodes = xpath.select(l.toXPath(), doc)
450+
expect(nodes).to.have.length(1, l.toXPath())
451+
})
452+
453+
it('withoutChild: excludes elements with a direct child match', () => {
454+
const l = Locator.build('p').withoutChild('span')
455+
const nodes = xpath.select(l.toXPath(), doc)
456+
expect(nodes).to.have.length(0, l.toXPath())
457+
})
458+
459+
it('and: appends raw xpath predicate', () => {
460+
const l = Locator.build('input').and('@type="checkbox"')
461+
const nodes = xpath.select(l.toXPath(), doc)
462+
expect(nodes).to.have.length(1, l.toXPath())
463+
})
464+
465+
it('andNot: wraps raw xpath predicate in not()', () => {
466+
const l = Locator.build('a').withClass('ps-menu-button').andNot('.//span[contains(@class, "ps-submenu-expand-icon")]')
467+
const nodes = xpath.select(l.toXPath(), doc)
468+
expect(nodes).to.have.length(1, l.toXPath())
469+
})
470+
410471
it('should build locator to match element containing a text', () => {
411472
const l = Locator.build('span').withText('Hey')
412473
const nodes = xpath.select(l.toXPath(), doc)

0 commit comments

Comments
 (0)