Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion docs/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,22 +237,61 @@ I.click(editAcme)

#### Builder methods

The `with*` family filters elements positively; `without*` excludes; `and` / `andNot` / `or` compose raw predicates or union locators.

| Method | Purpose | Example |
|--------|---------|---------|
| `find(loc)` | Descendant lookup | `locate('table').find('td')` |
| `withAttr(obj)` | Match attributes | `locate('input').withAttr({ placeholder: 'Name' })` |
| `withClassAttr(str)` | Class contains substring | `locate('div').withClassAttr('form')` |
| `withAttrContains(attr, str)` | Attr value contains substring | `locate('a').withAttrContains('href', 'google')` |
| `withAttrStartsWith(attr, str)` | Attr value starts with | `locate('a').withAttrStartsWith('href', 'https://')` |
| `withAttrEndsWith(attr, str)` | Attr value ends with | `locate('a').withAttrEndsWith('href', '.pdf')` |
| `withClass(...classes)` | Has all classes (word-exact) | `locate('button').withClass('btn-primary', 'btn-lg')` |
| `withClassAttr(str)` | Class attribute contains substring (legacy — prefer `withClass`) | `locate('div').withClassAttr('form')` |
| `withText(str)` | Visible text contains | `locate('span').withText('Warning')` |
| `withTextEquals(str)` | Visible text matches exactly | `locate('button').withTextEquals('Add')` |
| `withChild(loc)` | Has a direct child | `locate('form').withChild('select')` |
| `withDescendant(loc)` | Has a descendant anywhere below | `locate('tr').withDescendant('img.avatar')` |
| `withoutClass(...classes)` | None of these classes | `locate('tr').withoutClass('deleted')` |
| `withoutText(str)` | Visible text does not contain | `locate('li').withoutText('Archived')` |
| `withoutAttr(obj)` | None of these attr/value pairs | `locate('button').withoutAttr({ disabled: '' })` |
| `withoutChild(loc)` | No direct child matching | `locate('form').withoutChild('input[type=submit]')` |
| `withoutDescendant(loc)` | No descendant matching | `locate('button').withoutDescendant('svg')` |
| `inside(loc)` | Sits inside an ancestor | `locate('select').inside('form#user')` |
| `before(loc)` | Appears before another element | `locate('button').before('.btn-cancel')` |
| `after(loc)` | Appears after another element | `locate('button').after('.btn-cancel')` |
| `or(loc)` | Union of two locators | `locate('button.submit').or('input[type=submit]')` |
| `and(expr)` | Append raw XPath predicate | `locate('input').and('@type="text" or @type="email"')` |
| `andNot(expr)` | Append negated raw XPath predicate | `locate('button').andNot('.//svg')` |
| `first()` / `last()` | Bound position | `locate('#table td').first()` |
| `at(n)` | Pick nth element (negative counts from end) | `locate('#table td').at(-2)` |
| `as(name)` | Rename in logs | `locate('//table').as('orders table')` |

#### 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')
```

> `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.

## Custom locators

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.
Expand Down
112 changes: 112 additions & 0 deletions lib/locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<string, string>} 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}')`)
Expand Down
155 changes: 155 additions & 0 deletions test/unit/locator_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,161 @@ 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())
})

describe('combined filters', () => {
it('withClass + withoutClass: active vs inactive menu buttons', () => {
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('withClass + withAttr + withDescendant: dashboard menu with expand icon', () => {
const l = Locator.build('a')
.withClass('ps-menu-button')
.withAttr({ title: 'Dashboard' })
.withDescendant('.ps-submenu-expand-icon')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

it('withClass + withoutDescendant: single active menu without expand icon (user red-btn pattern)', () => {
const l = Locator.build('a').withClass('ps-menu-button', 'active').withoutDescendant('.ps-submenu-expand-icon')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

it('withText + withoutText: td with Edit but not Also Edit', () => {
const l = Locator.build('td').withText('Edit').withoutText('Also')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
expect(nodes[0].firstChild.data).to.eql('Edit')
})

it('withClass + withDescendant(locate(...).withTextEquals(...)): Authoring menu item', () => {
const l = Locator.build('a')
.withClass('ps-menu-button')
.withDescendant(Locator.build('span').withTextEquals('Authoring'))
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

it('withClass + withDescendant(nested withClass) + withoutDescendant', () => {
// active home menu, reached via its icon
const l = Locator.build('a')
.withClass('ps-menu-button', 'active')
.withDescendant(Locator.build('i').withClass('icon', 'home'))
.withoutDescendant('.ps-submenu-expand-icon')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

it('or: union of two distinct filtered locators', () => {
const active = Locator.build('a').withClass('ps-menu-button', 'active')
const dashboard = Locator.build('a').withAttr({ title: 'Dashboard' })
const l = active.or(dashboard)
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(2, l.toXPath())
})

it('and: raw predicate combined with DSL filters', () => {
const l = Locator.build('a').withClass('ps-menu-button').and('@title="Dashboard"')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

it('andNot + withClass: class present but no matching descendant', () => {
const l = Locator.build('li').withClass('ps-submenu-root').andNot('.//span[text()="Authoring"]')
const nodes = xpath.select(l.toXPath(), doc)
// 9 submenu-root items total, 1 contains "Authoring" → 8 remain
expect(nodes).to.have.length(8, l.toXPath())
})

it('deep chain: find + withClass + first + find + withText', () => {
const l = Locator.build('#fieldset-buttons').find('tr').first().find('td').withText('Edit').withoutText('Also')
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
expect(nodes[0].firstChild.data).to.eql('Edit')
})

it('withClass + withoutChild: submenu-root li with no child named `i`', () => {
const l = Locator.build('li').withClass('ps-submenu-root').withoutChild('i')
const nodes = xpath.select(l.toXPath(), doc)
// every submenu li has no direct `i` child (i is wrapped in a span) — all 9 match
expect(nodes).to.have.length(9, l.toXPath())
})

it('user button example: multi-class + text + not-descendant (applied to menu fixture)', () => {
// mirrors:
// locate('button').withClass('red-btn', 'btn-lg').withText('Save').withoutDescendant('svg')
const l = Locator.build('a')
.withClass('ps-menu-button', 'active')
.withText('aaa')
.withoutDescendant('.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)
Expand Down