diff --git a/README.md b/README.md index bceba70..7cbd1a8 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,57 @@ it('finds list items', () => { }) ``` +You can also chain `xpath` off of another command. + +```js +it('finds list items', () => { + cy.xpath('//ul[@class="todo-list"]') + .xpath('./li') + .should('have.length', 3) +}) +``` + +As with other cy commands, it is scoped by `cy.within()`. + +```js +it('finds list items', () => { + cy.xpath('//ul[@class="todo-list"]').within(() => { + cy.xpath('./li') + .should('have.length', 3) + }); +}) +``` + **note:** you can test XPath expressions from DevTools console using `$x(...)` function, for example `$x('//div')` to find all divs. See [cypress/integration/spec.js](cypress/integration/spec.js) +## Beware the XPath // trap + +In XPath the expression // means something very specific, and it might not be what you think. Contrary to common belief, // means "anywhere in the document" not "anywhere in the current context". As an example: + +```js +cy.xpath('//body') + .xpath('//script') +``` + +You might expect this to find all script tags in the body, but actually, it finds all script tags in the entire document, not only those in the body! What you're looking for is the .// expression which means "any descendant of the current node": + +```js +cy.xpath('//body') + .xpath('.//script') +``` + +The same thing goes for within: + +```js +cy.xpath('//body').within(() => { + cy.xpath('.//script') +}) +``` + +This explanation was shamelessly copied from [teamcapybara/capybara][capybara-xpath-trap]. + ## Roadmap - [x] wrap returned DOM nodes in jQuery [#2](https://github.com/cypress-io/cypress-xpath/issues/2) @@ -43,3 +90,4 @@ This project is licensed under the terms of the [MIT license](/LICENSE.md). [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg [renovate-app]: https://renovateapp.com/ +[capybara-xpath-trap]: https://github.com/teamcapybara/capybara/tree/3.18.0#beware-the-xpath--trap \ No newline at end of file diff --git a/cypress/integration/spec.js b/cypress/integration/spec.js index 8e51bdc..98492a8 100644 --- a/cypress/integration/spec.js +++ b/cypress/integration/spec.js @@ -46,6 +46,57 @@ describe('cypress-xpath', () => { cy.xpath('string(//*[@id="inserted"])').should('equal', 'inserted text') }) + describe('chaining', () => { + it('finds h1 within main', () => { + // first assert that h1 doesn't exist as a child of the implicit document subject + cy.xpath('./h1').should('not.exist') + + cy.xpath('//main').xpath('./h1').should('exist') + }) + + it('finds body outside of main when succumbing to // trap', () => { + // first assert that body doesn't actually exist within main + cy.xpath('//main').xpath('.//body').should('not.exist') + + cy.xpath('//main').xpath('//body').should('exist') + }) + + it('finds h1 within document', () => { + cy.document().xpath('//h1').should('exist') + }) + + it('throws when subject is more than a single element', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('xpath() can only be called on a single element. Your subject contained 2 elements.') + done() + }) + + cy.get('main, div').xpath('foo') + }) + }) + + describe('within()', () => { + it('finds h1 within within-subject', () => { + // first assert that h1 doesn't exist as a child of the implicit document subject + cy.xpath('./h1').should('not.exist') + + cy.xpath('//main').within(() => { + cy.xpath('./h1').should('exist') + }) + }) + + it('finds body outside of within-subject when succumbing to // trap', () => { + // first assert that body doesn't actually exist within main + cy.xpath('//main').within(() => { + cy.xpath('.//body').should('not.exist') + }); + + cy.xpath('//main').within(() => { + cy.xpath('//body').should('exist') + }); + }) + }) + describe('primitives', () => { it('counts h1 elements', () => { cy.xpath('count(//h1)').should('equal', 1) diff --git a/src/index.js b/src/index.js index b367140..a37d4eb 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,7 @@ }) ``` */ -const xpath = (selector, options = {}) => { +const xpath = (subject, selector, options = {}) => { /* global XPathResult */ const isNumber = (xpathResult) => xpathResult.resultType === XPathResult.NUMBER_TYPE const numberResult = (xpathResult) => xpathResult.numberValue @@ -33,10 +33,26 @@ const xpath = (selector, options = {}) => { message: selector, } + if (Cypress.dom.isElement(subject) && subject.length > 1) { + throw new Error('xpath() can only be called on a single element. Your subject contained ' + subject.length + ' elements.') + } + const getValue = () => { let nodes = [] - const document = cy.state('window').document - let iterator = document.evaluate(selector, document) + let contextNode + let withinSubject = cy.state('withinSubject') + + if (Cypress.dom.isElement(subject)) { + contextNode = subject[0] + } else if (Cypress.dom.isDocument(subject)) { + contextNode = subject + } else if (withinSubject) { + contextNode = withinSubject[0] + } else { + contextNode = cy.state('window').document + } + + let iterator = document.evaluate(selector, contextNode) if (isNumber(iterator)) { const result = numberResult(iterator) @@ -116,4 +132,4 @@ const xpath = (selector, options = {}) => { } -Cypress.Commands.add('xpath', xpath) +Cypress.Commands.add('xpath', { prevSubject: ['optional', 'element', 'document'] }, xpath)