diff --git a/docs/locators.md b/docs/locators.md index 6e13f0abb..0dc2b96d1 100644 --- a/docs/locators.md +++ b/docs/locators.md @@ -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. 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..3cad18b20 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -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)