Skip to content

Commit cfb64dd

Browse files
DavertMikDavertMikclaude
authored
feat(locator): add withClass, negation, and raw-predicate helpers to builder DSL (#5524)
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: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e782270 commit cfb64dd

File tree

3 files changed

+307
-1
lines changed

3 files changed

+307
-1
lines changed

docs/locators.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,22 +237,61 @@ I.click(editAcme)
237237

238238
#### Builder methods
239239

240+
The `with*` family filters elements positively; `without*` excludes; `and` / `andNot` / `or` compose raw predicates or union locators.
241+
240242
| Method | Purpose | Example |
241243
|--------|---------|---------|
242244
| `find(loc)` | Descendant lookup | `locate('table').find('td')` |
243245
| `withAttr(obj)` | Match attributes | `locate('input').withAttr({ placeholder: 'Name' })` |
244-
| `withClassAttr(str)` | Class contains substring | `locate('div').withClassAttr('form')` |
246+
| `withAttrContains(attr, str)` | Attr value contains substring | `locate('a').withAttrContains('href', 'google')` |
247+
| `withAttrStartsWith(attr, str)` | Attr value starts with | `locate('a').withAttrStartsWith('href', 'https://')` |
248+
| `withAttrEndsWith(attr, str)` | Attr value ends with | `locate('a').withAttrEndsWith('href', '.pdf')` |
249+
| `withClass(...classes)` | Has all classes (word-exact) | `locate('button').withClass('btn-primary', 'btn-lg')` |
250+
| `withClassAttr(str)` | Class attribute contains substring (legacy — prefer `withClass`) | `locate('div').withClassAttr('form')` |
245251
| `withText(str)` | Visible text contains | `locate('span').withText('Warning')` |
246252
| `withTextEquals(str)` | Visible text matches exactly | `locate('button').withTextEquals('Add')` |
247253
| `withChild(loc)` | Has a direct child | `locate('form').withChild('select')` |
248254
| `withDescendant(loc)` | Has a descendant anywhere below | `locate('tr').withDescendant('img.avatar')` |
255+
| `withoutClass(...classes)` | None of these classes | `locate('tr').withoutClass('deleted')` |
256+
| `withoutText(str)` | Visible text does not contain | `locate('li').withoutText('Archived')` |
257+
| `withoutAttr(obj)` | None of these attr/value pairs | `locate('button').withoutAttr({ disabled: '' })` |
258+
| `withoutChild(loc)` | No direct child matching | `locate('form').withoutChild('input[type=submit]')` |
259+
| `withoutDescendant(loc)` | No descendant matching | `locate('button').withoutDescendant('svg')` |
249260
| `inside(loc)` | Sits inside an ancestor | `locate('select').inside('form#user')` |
250261
| `before(loc)` | Appears before another element | `locate('button').before('.btn-cancel')` |
251262
| `after(loc)` | Appears after another element | `locate('button').after('.btn-cancel')` |
263+
| `or(loc)` | Union of two locators | `locate('button.submit').or('input[type=submit]')` |
264+
| `and(expr)` | Append raw XPath predicate | `locate('input').and('@type="text" or @type="email"')` |
265+
| `andNot(expr)` | Append negated raw XPath predicate | `locate('button').andNot('.//svg')` |
252266
| `first()` / `last()` | Bound position | `locate('#table td').first()` |
253267
| `at(n)` | Pick nth element (negative counts from end) | `locate('#table td').at(-2)` |
254268
| `as(name)` | Rename in logs | `locate('//table').as('orders table')` |
255269

270+
#### Translating complex XPath
271+
272+
Long XPath expressions become readable with the DSL. For example:
273+
274+
```
275+
//*[self::button
276+
and contains(@class,"red-btn")
277+
and contains(@class,"btn-text-and-icon")
278+
and contains(@class,"btn-lg")
279+
and contains(@class,"btn-selected")
280+
and normalize-space(.)="Button selected"
281+
and not(.//svg)]
282+
```
283+
284+
becomes:
285+
286+
```js
287+
locate('button')
288+
.withClass('red-btn', 'btn-text-and-icon', 'btn-lg', 'btn-selected')
289+
.withText('Button selected')
290+
.withoutDescendant('svg')
291+
```
292+
293+
> `withClass` uses word-exact matching (same as CSS `.foo`), so `.withClass('btn')` will not accidentally match `class="btn-lg"`. Use `withAttrContains('class', …)` if you need the old substring behavior.
294+
256295
## Custom locators
257296

258297
Teams that tag elements with `data-qa`, `data-test`, or similar attributes can register a short-form syntax instead of typing `{ css: '[data-qa-id=register_button]' }` every time.

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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,161 @@ 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+
471+
describe('combined filters', () => {
472+
it('withClass + withoutClass: active vs inactive menu buttons', () => {
473+
const l = Locator.build('a').withClass('ps-menu-button').withoutClass('active')
474+
const nodes = xpath.select(l.toXPath(), doc)
475+
expect(nodes).to.have.length(9, l.toXPath())
476+
})
477+
478+
it('withClass + withAttr + withDescendant: dashboard menu with expand icon', () => {
479+
const l = Locator.build('a')
480+
.withClass('ps-menu-button')
481+
.withAttr({ title: 'Dashboard' })
482+
.withDescendant('.ps-submenu-expand-icon')
483+
const nodes = xpath.select(l.toXPath(), doc)
484+
expect(nodes).to.have.length(1, l.toXPath())
485+
})
486+
487+
it('withClass + withoutDescendant: single active menu without expand icon (user red-btn pattern)', () => {
488+
const l = Locator.build('a').withClass('ps-menu-button', 'active').withoutDescendant('.ps-submenu-expand-icon')
489+
const nodes = xpath.select(l.toXPath(), doc)
490+
expect(nodes).to.have.length(1, l.toXPath())
491+
})
492+
493+
it('withText + withoutText: td with Edit but not Also Edit', () => {
494+
const l = Locator.build('td').withText('Edit').withoutText('Also')
495+
const nodes = xpath.select(l.toXPath(), doc)
496+
expect(nodes).to.have.length(1, l.toXPath())
497+
expect(nodes[0].firstChild.data).to.eql('Edit')
498+
})
499+
500+
it('withClass + withDescendant(locate(...).withTextEquals(...)): Authoring menu item', () => {
501+
const l = Locator.build('a')
502+
.withClass('ps-menu-button')
503+
.withDescendant(Locator.build('span').withTextEquals('Authoring'))
504+
const nodes = xpath.select(l.toXPath(), doc)
505+
expect(nodes).to.have.length(1, l.toXPath())
506+
})
507+
508+
it('withClass + withDescendant(nested withClass) + withoutDescendant', () => {
509+
// active home menu, reached via its icon
510+
const l = Locator.build('a')
511+
.withClass('ps-menu-button', 'active')
512+
.withDescendant(Locator.build('i').withClass('icon', 'home'))
513+
.withoutDescendant('.ps-submenu-expand-icon')
514+
const nodes = xpath.select(l.toXPath(), doc)
515+
expect(nodes).to.have.length(1, l.toXPath())
516+
})
517+
518+
it('or: union of two distinct filtered locators', () => {
519+
const active = Locator.build('a').withClass('ps-menu-button', 'active')
520+
const dashboard = Locator.build('a').withAttr({ title: 'Dashboard' })
521+
const l = active.or(dashboard)
522+
const nodes = xpath.select(l.toXPath(), doc)
523+
expect(nodes).to.have.length(2, l.toXPath())
524+
})
525+
526+
it('and: raw predicate combined with DSL filters', () => {
527+
const l = Locator.build('a').withClass('ps-menu-button').and('@title="Dashboard"')
528+
const nodes = xpath.select(l.toXPath(), doc)
529+
expect(nodes).to.have.length(1, l.toXPath())
530+
})
531+
532+
it('andNot + withClass: class present but no matching descendant', () => {
533+
const l = Locator.build('li').withClass('ps-submenu-root').andNot('.//span[text()="Authoring"]')
534+
const nodes = xpath.select(l.toXPath(), doc)
535+
// 9 submenu-root items total, 1 contains "Authoring" → 8 remain
536+
expect(nodes).to.have.length(8, l.toXPath())
537+
})
538+
539+
it('deep chain: find + withClass + first + find + withText', () => {
540+
const l = Locator.build('#fieldset-buttons').find('tr').first().find('td').withText('Edit').withoutText('Also')
541+
const nodes = xpath.select(l.toXPath(), doc)
542+
expect(nodes).to.have.length(1, l.toXPath())
543+
expect(nodes[0].firstChild.data).to.eql('Edit')
544+
})
545+
546+
it('withClass + withoutChild: submenu-root li with no child named `i`', () => {
547+
const l = Locator.build('li').withClass('ps-submenu-root').withoutChild('i')
548+
const nodes = xpath.select(l.toXPath(), doc)
549+
// every submenu li has no direct `i` child (i is wrapped in a span) — all 9 match
550+
expect(nodes).to.have.length(9, l.toXPath())
551+
})
552+
553+
it('user button example: multi-class + text + not-descendant (applied to menu fixture)', () => {
554+
// mirrors:
555+
// locate('button').withClass('red-btn', 'btn-lg').withText('Save').withoutDescendant('svg')
556+
const l = Locator.build('a')
557+
.withClass('ps-menu-button', 'active')
558+
.withText('aaa')
559+
.withoutDescendant('.ps-submenu-expand-icon')
560+
const nodes = xpath.select(l.toXPath(), doc)
561+
expect(nodes).to.have.length(1, l.toXPath())
562+
})
563+
})
564+
410565
it('should build locator to match element containing a text', () => {
411566
const l = Locator.build('span').withText('Hey')
412567
const nodes = xpath.select(l.toXPath(), doc)

0 commit comments

Comments
 (0)