Skip to content

Commit 3c81140

Browse files
Merge pull request #23878 from cypress-io/jordanpowell/cypress-xpath
feat(xpath): move cypress-xpath to @cypress/xpath
2 parents 070b3c9 + 3547a0e commit 3c81140

14 files changed

+576
-0
lines changed

.circleci/config.yml

+14
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,20 @@ jobs:
18731873
name: Build
18741874
command: yarn workspace @cypress/mount-utils build
18751875
- store-npm-logs
1876+
1877+
npm-xpath:
1878+
<<: *defaults
1879+
resource_class: small
1880+
steps:
1881+
- restore_cached_workspace
1882+
- run:
1883+
name: Run tests
1884+
command: yarn workspace @cypress/xpath cy:run
1885+
- store_test_results:
1886+
path: npm/xpath/test_results
1887+
- store_artifacts:
1888+
path: npm/xpath/test_results
1889+
- store-npm-logs
18761890

18771891
npm-create-cypress-tests:
18781892
<<: *defaults

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ system-tests/lib/fixtureDirs.ts
7878
# from npm/webpack-dev-server
7979
/npm/webpack-dev-server/cypress/videos
8080

81+
# from npm/xpath
82+
/npm/xpath/cypress/videos
83+
8184
# from errors
8285
/packages/errors/__snapshot-images__
8386
/packages/errors/__snapshot-md__

npm/xpath/.eslintrc

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"plugins": [
3+
"cypress"
4+
],
5+
"extends": [
6+
"plugin:@cypress/dev/tests"
7+
],
8+
"env": {
9+
"cypress/globals": true
10+
},
11+
"rules": {
12+
"mocha/no-global-tests": "off",
13+
"no-unused-vars": "off",
14+
"no-console": "off",
15+
"@typescript-eslint/no-unused-vars": "off"
16+
}
17+
}

npm/xpath/.releaserc.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
...require('../../.releaserc.base'),
3+
branches: [
4+
{ name: 'develop', channel: 'latest' },
5+
],
6+
}

npm/xpath/README.md

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# @cypress/xpath
2+
3+
> Adds XPath command to [Cypress.io](https://www.cypress.io) test runner
4+
5+
## Install with npm
6+
7+
```shell
8+
npm install -D @cypress/xpath
9+
```
10+
11+
## Install with Yarn
12+
13+
```shell
14+
yarn add @cypress/xpath --dev
15+
```
16+
17+
Then include in your project's [support file](https://on.cypress.io/support-file)
18+
19+
```js
20+
require('@cypress/xpath');
21+
```
22+
23+
## Use
24+
25+
After installation your `cy` object will have `xpath` command.
26+
27+
```js
28+
it('finds list items', () => {
29+
cy.xpath('//ul[@class="todo-list"]//li').should('have.length', 3);
30+
});
31+
```
32+
33+
You can also chain `xpath` off of another command.
34+
35+
```js
36+
it('finds list items', () => {
37+
cy.xpath('//ul[@class="todo-list"]').xpath('./li').should('have.length', 3);
38+
});
39+
```
40+
41+
As with other cy commands, it is scoped by `cy.within()`.
42+
43+
```js
44+
it('finds list items', () => {
45+
cy.xpath('//ul[@class="todo-list"]').within(() => {
46+
cy.xpath('./li').should('have.length', 3);
47+
});
48+
});
49+
```
50+
51+
**note:** you can test XPath expressions from DevTools console using `$x(...)` function, for example `$x('//div')` to find all divs.
52+
53+
See [cypress/e2e/spec.cy.js](cypress/e2e/spec.cy.js)
54+
55+
## Beware the XPath // trap
56+
57+
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:
58+
59+
```js
60+
cy.xpath('//body').xpath('//script');
61+
```
62+
63+
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":
64+
65+
```js
66+
cy.xpath('//body').xpath('.//script');
67+
```
68+
69+
The same thing goes for within:
70+
71+
```js
72+
cy.xpath('//body').within(() => {
73+
cy.xpath('.//script');
74+
});
75+
```
76+
77+
78+
For more, see [Intelligent Code Completion](https://on.cypress.io/intellisense)
79+
80+
## License
81+
82+
This project is licensed under the terms of the [MIT license](/LICENSE.md).

npm/xpath/cypress.config.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const { defineConfig } = require('cypress')
2+
3+
module.exports = defineConfig({
4+
e2e: {
5+
excludeSpecPattern: '*.html',
6+
supportFile: 'cypress/support/e2e.js',
7+
},
8+
component: {
9+
excludeSpecPattern: '*.html',
10+
supportFile: 'cypress/support/e2e.js',
11+
},
12+
})

npm/xpath/cypress/e2e/index.html

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<body>
2+
<main>
3+
<h1>cypress-xpath</h1>
4+
<button id="first-button">Click me</button>
5+
<div>
6+
<input id="name" type="text" placeholder="Your name?" />
7+
<span id="greeting"></span>
8+
</div>
9+
</main>
10+
<script>
11+
document.getElementById('first-button').addEventListener('click', () => {
12+
window.alert('you clicked first button')
13+
})
14+
setTimeout(() => {
15+
document
16+
.querySelector('main')
17+
.insertAdjacentHTML(
18+
'afterend',
19+
'<div id="inserted">inserted text</div>'
20+
)
21+
}, 1000)
22+
document.getElementById('name').addEventListener('input', e => {
23+
document.getElementById('greeting').innerText = `Hello, ${e.target.value}`
24+
})
25+
</script>
26+
</body>

npm/xpath/cypress/e2e/spec.cy.js

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/// <reference types="cypress" />
2+
/// <reference path="../../src/index.d.ts" />
3+
4+
describe('cypress-xpath', () => {
5+
it('adds xpath command', () => {
6+
expect(cy).property('xpath').to.be.a('function')
7+
})
8+
9+
context('elements', () => {
10+
beforeEach(() => {
11+
cy.visit('cypress/e2e/index.html')
12+
})
13+
14+
it('finds h1', () => {
15+
cy.xpath('//h1').should('have.length', 1)
16+
})
17+
18+
it('returns jQuery wrapped elements', () => {
19+
cy.xpath('//h1').then((el$) => {
20+
expect(el$).to.have.property('jquery')
21+
})
22+
})
23+
24+
it('returns primitives as is', () => {
25+
cy.xpath('string(//h1)').then((el$) => {
26+
expect(el$).to.not.have.property('jquery')
27+
})
28+
})
29+
30+
it('provides jQuery wrapped elements to assertions', () => {
31+
cy.xpath('//h1').should((el$) => {
32+
expect(el$).to.have.property('jquery')
33+
})
34+
})
35+
36+
it('gets h1 text', () => {
37+
cy.xpath('//h1/text()')
38+
.its('0.textContent')
39+
.should('equal', 'cypress-xpath')
40+
})
41+
42+
it('retries until element is inserted', () => {
43+
// the element will be inserted after 1 second
44+
cy.xpath('string(//*[@id="inserted"])').should('equal', 'inserted text')
45+
})
46+
47+
describe('chaining', () => {
48+
it('finds h1 within main', () => {
49+
// first assert that h1 doesn't exist as a child of the implicit document subject
50+
cy.xpath('./h1').should('not.exist')
51+
52+
cy.xpath('//main').xpath('./h1').should('exist')
53+
})
54+
55+
it('finds body outside of main when succumbing to // trap', () => {
56+
// first assert that body doesn't actually exist within main
57+
cy.xpath('//main').xpath('.//body').should('not.exist')
58+
59+
cy.xpath('//main').xpath('//body').should('exist')
60+
})
61+
62+
it('finds h1 within document', () => {
63+
cy.document().xpath('//h1').should('exist')
64+
})
65+
66+
it('throws when subject is more than a single element', (done) => {
67+
cy.on('fail', (err) => {
68+
expect(err.message).to.eq(
69+
'xpath() can only be called on a single element. Your subject contained 2 elements.',
70+
)
71+
72+
done()
73+
})
74+
75+
cy.get('main, div').xpath('foo')
76+
})
77+
})
78+
79+
describe('within()', () => {
80+
it('finds h1 within within-subject', () => {
81+
// first assert that h1 doesn't exist as a child of the implicit document subject
82+
cy.xpath('./h1').should('not.exist')
83+
84+
cy.xpath('//main').within(() => {
85+
cy.xpath('./h1').should('exist')
86+
})
87+
})
88+
89+
it('finds body outside of within-subject when succumbing to // trap', () => {
90+
// first assert that body doesn't actually exist within main
91+
cy.xpath('//main').within(() => {
92+
cy.xpath('.//body').should('not.exist')
93+
})
94+
95+
cy.xpath('//main').within(() => {
96+
cy.xpath('//body').should('exist')
97+
})
98+
})
99+
})
100+
101+
describe('primitives', () => {
102+
it('counts h1 elements', () => {
103+
cy.xpath('count(//h1)').should('equal', 1)
104+
})
105+
106+
it('returns h1 text content', () => {
107+
cy.xpath('string(//h1)').should('equal', 'cypress-xpath')
108+
})
109+
110+
it('returns boolean', () => {
111+
cy.xpath('boolean(//h1)').should('be.true')
112+
cy.xpath('boolean(//h2)').should('be.false')
113+
})
114+
})
115+
116+
describe('typing', () => {
117+
it('works on text input', () => {
118+
cy.xpath('//*[@id="name"]').type('World')
119+
cy.contains('span#greeting', 'Hello, World')
120+
})
121+
})
122+
123+
describe('clicking', () => {
124+
it('on button', () => {
125+
// this button invokes window.alert when clicked
126+
const alert = cy.stub()
127+
128+
cy.on('window:alert', alert)
129+
cy.xpath('//*[@id="first-button"]')
130+
.click()
131+
.then(() => {
132+
expect(alert).to.have.been.calledOnce
133+
})
134+
})
135+
})
136+
})
137+
138+
context('logging', () => {
139+
beforeEach(() => {
140+
cy.visit('cypress/e2e/index.html')
141+
})
142+
143+
it('should log by default', () => {
144+
cy.spy(Cypress, 'log').log(false)
145+
146+
cy.xpath('//h1').then(() => {
147+
expect(Cypress.log).to.be.calledWithMatch({ name: 'xpath' })
148+
})
149+
})
150+
151+
it('logs the selector when not found', (done) => {
152+
cy.xpath('//h1') // does exist
153+
cy.on('fail', (e) => {
154+
const isExpectedErrorMessage = (message) => {
155+
return message.includes('Timed out retrying') &&
156+
message.includes(
157+
'Expected to find element: `//h2`, but never found it.',
158+
)
159+
}
160+
161+
if (!isExpectedErrorMessage(e.message)) {
162+
console.error('Cypress test failed with an unexpected error message')
163+
console.error(e)
164+
165+
return done(e)
166+
}
167+
168+
// no errors, the error message for not found selector is correct
169+
done()
170+
})
171+
172+
cy.xpath('//h2', { timeout: 100 }) // does not exist
173+
})
174+
175+
it('should not log when provided log: false', () => {
176+
cy.spy(Cypress, 'log').log(false)
177+
178+
cy.xpath('//h1', { log: false }).then(() => {
179+
expect(Cypress.log).to.not.be.calledWithMatch({ name: 'xpath' })
180+
})
181+
})
182+
})
183+
})
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "Using fixtures to represent data",
3+
"email": "hello@cypress.io",
4+
"body": "Fixtures are a great way to mock data for responses to routes"
5+
}

npm/xpath/cypress/support/e2e.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// ***********************************************************
2+
// This example support/index.js is processed and
3+
// loaded automatically before your test files.
4+
//
5+
// This is a great place to put global configuration and
6+
// behavior that modifies Cypress.
7+
//
8+
// You can change the location of this file or turn off
9+
// automatically serving support files with the
10+
// 'supportFile' configuration option.
11+
//
12+
// You can read more here:
13+
// https://on.cypress.io/configuration
14+
// ***********************************************************
15+
16+
import '../../src'
691 KB
Loading

0 commit comments

Comments
 (0)