├── .github └── workflows │ ├── badges.yml │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── cypress.config.js ├── cypress ├── dataset.html ├── detach.html ├── e2e │ ├── apply.cy.js │ ├── as-env.cy.js │ ├── assertions │ │ ├── possess.cy.js │ │ └── read.cy.js │ ├── at.cy.js │ ├── chain-with-assertions.cy.js │ ├── detach.cy.js │ ├── diff.cy.js │ ├── difference.cy.js │ ├── elements.cy.js │ ├── filter-table.cy.js │ ├── filter-table │ │ ├── filter.js │ │ ├── index.html │ │ └── style.css │ ├── filter.cy.js │ ├── find-one.cy.js │ ├── get-in-order.cy.js │ ├── import-library.js │ ├── instance.cy.js │ ├── invoke-first.cy.js │ ├── invoke-once.cy.js │ ├── json-attribute │ │ ├── index.html │ │ └── spec.cy.js │ ├── log-examples.cy.js │ ├── make.cy.js │ ├── map-chain.cy.js │ ├── map-invoke.cy.js │ ├── map.cy.js │ ├── multiple-import.cy.js │ ├── partial.cy.js │ ├── primo.cy.js │ ├── print.cy.js │ ├── prop.cy.js │ ├── reduce.cy.js │ ├── sample.cy.js │ ├── second.cy.js │ ├── stable-css │ │ ├── index.html │ │ └── stable-css.cy.js │ ├── stable │ │ ├── index.html │ │ └── stable.cy.js │ ├── table-rows.cy.js │ ├── table.cy.js │ ├── tap.cy.js │ ├── text.cy.js │ ├── third.cy.js │ ├── to-plain-object.cy.js │ ├── unique.cy.js │ └── version.cy.js ├── fixtures │ └── people.json ├── index.html ├── list.html ├── log-examples.html ├── prices.html └── table.html ├── images └── table.png ├── package-lock.json ├── package.json ├── renovate.json ├── src └── commands │ ├── apply.js │ ├── as-env.js │ ├── assertions.js │ ├── at.js │ ├── detaches.js │ ├── difference.js │ ├── elements.js │ ├── find-one.js │ ├── get-in-order.js │ ├── index.d.ts │ ├── index.js │ ├── invoke-first.js │ ├── invoke-once.js │ ├── make.js │ ├── map-chain.js │ ├── map-invoke.js │ ├── map.js │ ├── partial.js │ ├── primo.js │ ├── print.js │ ├── prop.js │ ├── reduce.js │ ├── sample.js │ ├── second.js │ ├── stable.ts │ ├── table.js │ ├── tap.js │ ├── third.js │ ├── to-plain-object.js │ ├── update.js │ ├── utils.js │ └── version-check.js └── tsconfig.json /.github/workflows/badges.yml: -------------------------------------------------------------------------------- 1 | name: badges 2 | on: 3 | schedule: 4 | # update badges every night 5 | # because we have a few badges that are linked 6 | # to the external repositories 7 | - cron: '0 3 * * *' 8 | 9 | jobs: 10 | badges: 11 | name: Badges 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - name: Checkout 🛎 15 | uses: actions/checkout@v4 16 | 17 | - name: Update version badges 🏷 18 | run: npm run badges 19 | 20 | - name: Commit any changed files 💾 21 | uses: stefanzweifel/git-auto-commit-action@v5 22 | with: 23 | commit_message: Updated badges 24 | branch: main 25 | file_pattern: README.md 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-24.04 6 | steps: 7 | - name: Checkout 🛎 8 | uses: actions/checkout@v4 9 | 10 | - name: Install everything 📦 11 | # https://github.com/cypress-io/github-action 12 | uses: cypress-io/github-action@v6 13 | with: 14 | runTests: false 15 | 16 | # make sure we did not leave "it.only" accidentally 17 | # https://github.com/bahmutov/stop-only 18 | - name: Catch "it.only" 🫴 19 | run: npm run stop-only 20 | 21 | - name: Lint code ☑️ 22 | run: npm run lint 23 | 24 | test: 25 | # https://github.com/bahmutov/cypress-workflows 26 | uses: bahmutov/cypress-workflows/.github/workflows/split.yml@v2 27 | with: 28 | nE2E: 3 29 | build: npm run build 30 | 31 | release: 32 | needs: [lint, test] 33 | runs-on: ubuntu-24.04 34 | if: github.ref == 'refs/heads/main' 35 | steps: 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version: 20 39 | 40 | - name: Checkout 🛎 41 | uses: actions/checkout@v4 42 | 43 | - name: Install everything 📦 44 | # https://github.com/cypress-io/github-action 45 | uses: cypress-io/github-action@v6 46 | with: 47 | runTests: false 48 | 49 | - name: Build dist 🏗 50 | run: npm run build 51 | 52 | - name: Semantic Release 🚀 53 | uses: cycjimmy/semantic-release-action@v4 54 | with: 55 | branch: main 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cypress/videos/ 3 | dist 4 | commands/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.15.0 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 70 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 bahmutov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-map [![ci](https://github.com/bahmutov/cypress-map/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bahmutov/cypress-map/actions/workflows/ci.yml) ![cypress version](https://img.shields.io/badge/cypress-14.0.0-brightgreen) 2 | 3 | > Extra Cypress query commands for v12+ 4 | 5 | - 📺 Watch the videos: 6 | - [Cypress v12 Querying Commands Introduction](https://youtu.be/4HpEECek2OE) 7 | - [Confirm Table Column](https://youtu.be/UOLQlNmuhY0) 8 | - [Use cypress-map Queries To Validate A Row In A Table](https://youtu.be/eVe4ySgW0qw) 9 | - [How To Check Visibility Of Many Elements](https://youtu.be/puCZGCeUb5k) 10 | - [Pick A Random Menu Link](https://youtu.be/xvvL3GRjXCY) 11 | - [Confirm The Same Text In A Couple Of Elements](https://youtu.be/xvImOlCSul4) 12 | - [Map Input Elements Values](https://youtu.be/OmVzv6pJN6I) 13 | - [Iterate Over DOM Elements Using cy.each, jQuery each and map, and cypress-map Plugin Commands](https://youtu.be/tMeKIIfEhyo) 14 | - [Confirm Sorted Attributes](https://youtu.be/sVb5MU2AkqE) 15 | - [Query Multiple Elements In Order](https://youtu.be/3BjwoG1dW7o) 16 | - [Element's Text Becomes Stable](https://youtu.be/GrRUnQ2r7Wk) 17 | - [Cut Cypress Execution In Half By Running Tests In Parallel Using cypress-split And GitHub Actions](https://youtu.be/jvBzNs0pRXU) 18 | - [Fix GitHub Actions Node Version Warnings](https://youtu.be/1_jvJ3c8QAY) 19 | - [Use cy.second and cy.third Commands](https://youtu.be/gZtTN9LaD7U) 20 | - [Remove Class From Sampled Elements](https://youtu.be/zB2LYB0yFwQ) 21 | - [Check Multiple Properties At Once Using cy.difference Query](https://youtu.be/WKVaJjst_-8) 22 | - [cy.difference Command With Predicates](https://youtu.be/RKgSBN2fk_s) 23 | - [The Possess Assertion From cypress-map Plugin](https://youtu.be/HHxkL-BPyjA) 24 | - [Custom Should Read Assertion From Cypress-map Plugin](https://youtu.be/AzJx-8VD6yI) 25 | - [Introducing The cy.elements Query Command From Cypress-map Plugin](https://youtu.be/4mFZMQpzBIU) 26 | - 📝 Read the blog posts 27 | - [Cypress V12 Is A Big Deal](https://glebbahmutov.com/blog/cypress-v12/) 28 | - [Crawl Weather Using Cypress](https://glebbahmutov.com/blog/crawl-weather/) 29 | - [Pass Values Between Cypress Tests](https://glebbahmutov.com/blog/pass-values-between-tests/) 30 | - [Do Not Use SHA To Compare HTML During E2E Tests](https://glebbahmutov.com/blog/do-not-use-sha/) 31 | - [Cypress Flakiness Examples](https://glebbahmutov.com/blog/flakiness-example/) 32 | - [Solve Tough Pagination Cases Using Cypress](https://glebbahmutov.com/blog/solve-tough-pagination-cases-using-cypress/) 33 | - [Check Fees And Totals Using Cypress](https://glebbahmutov.com/blog/check-fees-using-cypress/) 34 | - [Custom Cypress Should Read Assertion](https://glebbahmutov.com/blog/cypress-map-should-read-assertion/) 35 | - 🎓 Covered in my course [Cypress Plugins](https://cypress.tips/courses/cypress-plugins) 36 | - [Lesson l1: Confirm the attribute of the last item](https://cypress.tips/courses/cypress-plugins/lessons/l1) 37 | - [Lesson l2: Confirm the extracted list of texts](https://cypress.tips/courses/cypress-plugins/lessons/l2) 38 | - [Lesson l3: Confirm the last two items in the extracted lis](https://cypress.tips/courses/cypress-plugins/lessons/l3) 39 | - [Lesson l4: Confirm the list of attributes](https://cypress.tips/courses/cypress-plugins/lessons/l4) 40 | - [Lesson l5: Confirm the sum of attributes](https://cypress.tips/courses/cypress-plugins/lessons/l5) 41 | - [Lesson l6: Compare the sum of attributes to the total](https://cypress.tips/courses/cypress-plugins/lessons/l6) 42 | - [Lesson l7: Debug chained queries using tap](https://cypress.tips/courses/cypress-plugins/lessons/l7) 43 | - [Lesson l8: Get the raw DOM element at index k](https://cypress.tips/courses/cypress-plugins/lessons/l8) 44 | - [Lesson l9: Confirm the number of elements with the given attribute](https://cypress.tips/courses/cypress-plugins/lessons/l9) 45 | - [Lesson l10: Check the parsed attribute value](https://cypress.tips/courses/cypress-plugins/lessons/l10) 46 | - [Lesson l11: Extract and convert prices](https://cypress.tips/courses/cypress-plugins/lessons/l11) 47 | - [Lesson l12: Find the element with the smallest attribute value](https://cypress.tips/courses/cypress-plugins/lessons/l12) 48 | - [Lesson l13: Check each item's text](https://cypress.tips/courses/cypress-plugins/lessons/l13) 49 | - [Lesson l14: Flexible logging using cy.print command](https://cypress.tips/courses/cypress-plugins/lessons/l14) 50 | - [Lesson l15: Confirm HTML table values](https://cypress.tips/courses/cypress-plugins/lessons/l15) 51 | - [Lesson l17: Control the network and confirm the table](https://cypress.tips/courses/cypress-plugins/lessons/l17) 52 | - 🎓 Covered in my course [Cypress Network Testing Exercises](https://cypress.tips/courses/network-testing) 53 | - [Bonus 92: Map each item from the list using Cy commands](https://cypress.tips/courses/network-testing/lessons/bonus92) 54 | - [Bonus 100: Check all network responses at once with auto retries](https://cypress.tips/courses/network-testing/lessons/bonus100) 55 | - 🎓 Covered in my course [Testing The Swag Store](https://cypress.tips/courses/swag-store) 56 | - [Lesson 23: Simplify getting attribute from a list of elements](https://cypress.tips/courses/swag-store/lessons/lesson23) 57 | - [Bonus 36: Manipulate data- attributes using cypress-map methods](https://cypress.tips/courses/swag-store/lessons/bonus36) 58 | - [Bonus 41: Pick and confirm a random product](https://cypress.tips/courses/swag-store/lessons/bonus41) 59 | - [Bonus 42: Add 3 random items to the cart](https://cypress.tips/courses/swag-store/lessons/bonus42) 60 | - [Bonus 127: Parse URLSearchParams with retries](https://cypress.tips/courses/network-testing/lessons/bonus127) 61 | 62 | ## Install 63 | 64 | Add this package as a dev dependency: 65 | 66 | ``` 67 | $ npm i -D cypress-map 68 | # or using Yarn 69 | $ yarn add -D cypress-map 70 | ``` 71 | 72 | Include this package in your spec or support file to use all custom query commands 73 | 74 | ```js 75 | import 'cypress-map' 76 | ``` 77 | 78 | Alternative: import only the query commands you need: 79 | 80 | ```js 81 | import 'cypress-map/commands/map' 82 | import 'cypress-map/commands/tap' 83 | // and so on, see the /commands folder 84 | ``` 85 | 86 | ## API 87 | 88 | ### apply 89 | 90 | ```js 91 | const double = (n) => n * 2 92 | cy.wrap(100).apply(double).should('equal', 200) 93 | ``` 94 | 95 | It works like `cy.then` but `cy.apply(fn)` is a query command. Function `fn` should be synchronous, pure function that only uses the subject argument and returns new value The function callback `fn` cannot use any Cypress commands `cy`. 96 | 97 | You can pass additional _left_ arguments to the callback function. Then it puts the subject as _last argument_ before calling the function: 98 | 99 | ```js 100 | cy.wrap(8).apply(Cypress._.subtract, 4).should('equal', -4) 101 | ``` 102 | 103 | ### applyRight 104 | 105 | Without arguments, `cy.applyRight` works the same as `cy.apply`. If you pass arguments, then the subject plus the arguments become the arguments to the callback. The subject is at the _left_ (first) position 106 | 107 | ```js 108 | cy.wrap(8).applyRight(Cypress._.subtract, 4).should('equal', 4) 109 | // same as 110 | cy.wrap(8) 111 | .apply((subject) => Cypress._.subtract(subject, 4)) 112 | .should('equal', 4) 113 | ``` 114 | 115 | ### partial 116 | 117 | Sometimes you have the callback to apply, and you know the first argument(s), and just need to put the subject at the last position. This is where you can partially apply the known arguments to the given callback. 118 | 119 | ```js 120 | // the Cypress._.add takes to arguments (a, b) 121 | // we know the first argument a = 5 122 | // so we partially apply it and wait for the subject = b argument 123 | cy.wrap(100).partial(Cypress._.add, 5).should('equal', 105) 124 | // same as 125 | cy.wrap(100) 126 | .apply((subject) => Cypress._.add(5, subject)) 127 | .should('equal', 105) 128 | ``` 129 | 130 | ### applyToFirst 131 | 132 | If the current subject is an array, or a jQuery object, you can apply the given callback with arguments to the _first_ item or element. The current subject will be the _last_ argument. 133 | 134 | ```js 135 | // cy.applyToFirst(callback, ...args) 136 | cy.wrap(Cypress.$('
100
200
')) 137 | .applyToFirst((base, el) => parseInt(el.innerText, base), 10) 138 | .should('equal', 100) 139 | ``` 140 | 141 | ### applyToFirstRight 142 | 143 | If the current subject is an array, or a jQuery object, you can apply the given callback with arguments to the _first_ item or element. The current subject will be the _first_ argument. 144 | 145 | ```js 146 | // cy.applyToFirstRight(callback, ...args) 147 | cy.wrap(Cypress.$('
100
200
')) 148 | .applyToFirstRight((el, base) => parseInt(el.innerText, base), 10) 149 | .should('equal', 100) 150 | ``` 151 | 152 | ### invokeFirst 153 | 154 | We often just need to call a method on the first element / item in the current subject 155 | 156 | ```js 157 | cy.get(selector).invokeFirst('getBoundingClientRect') 158 | // compute the vertical center for example 159 | ``` 160 | 161 | ### map 162 | 163 | Transforms every object in the given collection by running it through the given callback function. Can also map each object to its property. An object could be an array or a jQuery object. 164 | 165 | ```js 166 | // map elements by invoking a function 167 | cy.wrap(['10', '20', '30']).map(Number) // [10, 20, 30] 168 | // map elements by a property 169 | cy.get('.matching') 170 | .map('innerText') 171 | .should('deep.equal', ['first', 'third', 'fourth']) 172 | ``` 173 | 174 | You can even map properties of an object by listing callbacks. For example, let's convert the `age` property from a string to a number 175 | 176 | ```js 177 | cy.wrap({ 178 | age: '42', 179 | lucky: true, 180 | }) 181 | .map({ 182 | age: Number, 183 | }) 184 | .should('deep.equal', { 185 | age: 42, 186 | lucky: true, 187 | }) 188 | ``` 189 | 190 | You can avoid any conversion to simply pick the list of properties from an object 191 | 192 | ```js 193 | const person = { 194 | name: 'Joe', 195 | age: 21, 196 | occupation: 'student', 197 | } 198 | cy.wrap(person).map(['name', 'age']).should('deep.equal', { 199 | name: 'Joe', 200 | age: 21, 201 | }) 202 | ``` 203 | 204 | You can extract nested paths by using "." in your property path 205 | 206 | ```js 207 | cy.wrap(people) 208 | .map('name.first') 209 | .should('deep.equal', ['Joe', 'Anna']) 210 | // equivalent to 211 | cy.wrap(people) 212 | .map('name') 213 | .map('first') 214 | .should('deep.equal', ['Joe', 'Anna']) 215 | ``` 216 | 217 | If you want to pick multiple deep properties, the last segment will be the output property name 218 | 219 | ```js 220 | // each person has nested objects like 221 | // { name: { first: '...', last: '...', }, human: { age: xx, ... } } 222 | cy.wrap(people).map(['name.first', 'human.age']) 223 | // each object will have "first" and "age" properties 224 | ``` 225 | 226 | ### mapInvoke 227 | 228 | ```js 229 | cy.get('#items li') 230 | .find('.price') 231 | .map('innerText') 232 | .mapInvoke('replace', '$', '') 233 | .mapInvoke('trim') 234 | ``` 235 | 236 | ### reduce 237 | 238 | ```js 239 | cy.get('#items li') 240 | .find('.price') 241 | .map('innerText') 242 | .mapInvoke('replace', '$', '') 243 | .map(parseFloat) 244 | .reduce((max, n) => (n > max ? n : max)) 245 | // yields the highest price 246 | ``` 247 | 248 | You can provide the initial accumulator value 249 | 250 | ```js 251 | cy.wrap([1, 2, 3]) 252 | .reduce((sum, n) => sum + n, 10) 253 | .should('equal', 16) 254 | ``` 255 | 256 | See [reduce.cy.js](./cypress/e2e/reduce.cy.js) 257 | 258 | ### tap 259 | 260 | ```js 261 | cy.get('#items li') 262 | .find('.price') 263 | .map('innerText') 264 | .tap() // console.log by default 265 | .mapInvoke('replace', '$', '') 266 | .mapInvoke('trim') 267 | // console.info with extra label 268 | .tap(console.info, 'trimmed strings') 269 | ``` 270 | 271 | **Notice:** if the label is provided, the callback function is called with label and the subject. 272 | 273 | ### make 274 | 275 | A retryable query that calls the given constructor function using the `new` keyword and the current subject as argument. 276 | 277 | ```js 278 | cy.wrap('Jan 1, 2019') 279 | // same as "new Date('Jan 1, 2019')" 280 | .make(Date) 281 | .invoke('getFullYear') 282 | .should('equal', 2019) 283 | ``` 284 | 285 | ### print 286 | 287 | A better `cy.log`: yields the value, intelligently stringifies values using `%` and [string-format](https://github.com/davidchambers/string-format) notation. 288 | 289 | ```js 290 | cy.wrap(42) 291 | .print() // "42" 292 | // and yields the value 293 | .should('equal', 42) 294 | // pass formatting string 295 | cy.wrap(42).print('the answer is %d') // "the answer is 42" 296 | cy.wrap({ name: 'Joe' }).print('person %o') // 'person {"name":"Joe"}' 297 | // use {0} with dot notation, supported deep properties 298 | // https://github.com/davidchambers/string-format 299 | cy.wrap({ name: 'Joe' }).print('person name {0.name}') // "person name Joe" 300 | // print the length of an array 301 | cy.wrap(arr).print('array length {0.length}') // "array length ..." 302 | // pass your own function to return formatted string 303 | cy.wrap(arr).print((a) => `array with ${a.length} items`) 304 | // if you return a non-string, it will attempt to JSON.stringify it 305 | cy.wrap(arr).print((list) => list[2]) // JSON.stringify(arr[2]) 306 | ``` 307 | 308 | See [print.cy.js](./cypress/e2e/print.cy.js) for more examples 309 | 310 | ### findOne 311 | 312 | Finds a single item in the subject. Assumes subject is an array or a jQuery object. Uses Lodash `_.find` method. 313 | 314 | ```js 315 | // using predicate function 316 | const isThree = n => n === 3 317 | cy.wrap([...]).findOne(isThree).should('equal', 3) 318 | // using partial known properties of an object 319 | cy.wrap([...]).findOne({ name: 'Anna' }).should('have.property', 'name', 'Anna') 320 | ``` 321 | 322 | See [find-one.cy.js](./cypress/e2e/find-one.cy.js) 323 | 324 | ### primo 325 | 326 | ```js 327 | cy.get('.matching') 328 | .map('innerText') 329 | .primo() 330 | .invoke('toUpperCase') 331 | .should('equal', 'FIRST') 332 | ``` 333 | 334 | See [primo.cy.js](./cypress/e2e/primo.cy.js) 335 | 336 | ### prop 337 | 338 | Works like `cy.its` for objects, but gets the property for jQuery objects, which `cy.its` does not 339 | 340 | ```js 341 | cy.get('#items li.matching') 342 | .last() 343 | .prop('ariaLabel') 344 | .should('equal', 'four') 345 | ``` 346 | 347 | See [prop.cy.js](./cypress/e2e/prop.cy.js) 348 | 349 | ### update 350 | 351 | Changes a single property inside the subject by running it through the given callback function. Useful to do type conversions, for example, let's convert the "age" property to a Number 352 | 353 | ```js 354 | cy.wrap({ age: '20' }) 355 | .update('age', Number) 356 | .should('deep.equal', { age: 20 }) 357 | ``` 358 | 359 | ### at 360 | 361 | Returns a DOM element from jQuery object at position `k`. Returns an item from array at position `k`. For negative index, counts the items from the end. 362 | 363 | ```js 364 | cy.get('#items li').at(-1).its('innerText').should('equal', 'fifth') 365 | ``` 366 | 367 | See [at.cy.js](./cypress/e2e/at.cy.js) 368 | 369 | ### sample 370 | 371 | Returns a randomly picked item or element from the current subject 372 | 373 | ```js 374 | cy.get('#items li').sample().should('have.text', 'four') 375 | ``` 376 | 377 | If you pass a positive number, then it picks multiple elements or items 378 | 379 | ```js 380 | // yields jQuery object with 3 random items 381 | cy.get('#items li').sample(3).should('have.length', 3) 382 | ``` 383 | 384 | See [sample.cy.js](./cypress/e2e/sample.cy.js) 385 | 386 | ### second 387 | 388 | Yields the second element from the current subject. Could be an element or an array item. 389 | 390 | ```js 391 | cy.get('#items li').second().should('have.text', 'second') 392 | ``` 393 | 394 | See [second.cy.js](./cypress/e2e/second.cy.js) 395 | 396 | ### third 397 | 398 | Yields the third element from the current subject. Could be an element or an array item. 399 | 400 | ```js 401 | cy.get('#items li').third().should('have.text', 'third') 402 | ``` 403 | 404 | See [third.cy.js](./cypress/e2e/third.cy.js) 405 | 406 | ### asEnv 407 | 408 | Saves current subject in `Cypress.env` object. Note: Cypress.env object is reset before the spec run, but the changed values are passed from test to test. Thus you can easily pass a value from the first test to the second. 409 | 410 | ```js 411 | it('saves value in this test', () => { 412 | cy.wrap('hello, world').asEnv('greeting') 413 | }) 414 | 415 | it('saved value is available in this test', () => { 416 | expect(Cypress.env('greeting'), 'greeting').to.equal('hello, world') 417 | }) 418 | ``` 419 | 420 | Do you really want to make the tests dependent on each other? 421 | 422 | ### elements 423 | 424 | Often we need to find a list of elements with some sub-parts. The query `cy.elements` uses the parent selector plus child selectors and returns an array of arrays of strings. 425 | 426 | Given this HTML 427 | 428 | ```html 429 | 435 | ``` 436 | 437 | Find all items and the numbers. The parent selector is `#tasks li`, and inside the parts we want to get are `.k` and `.name` (in this order) 438 | 439 | ```js 440 | cy.elements('#tasks li', '.k', '.name').should('deep.equal', [ 441 | ['1', 'Item A'], 442 | ['2', 'Item B'], 443 | ['3', 'Item C'], 444 | ['4', 'Item D'], 445 | ]) 446 | ``` 447 | 448 | ### getInOrder 449 | 450 | Queries the page using multiple selectors and returns the found elements _in the specified_ order, no matter how they are ordered in the document. Retries if any of the selectors are not found. 451 | 452 | ```js 453 | cy.getInOrder('selector1', 'selector2', 'selector3', ...) 454 | // yields a single jQuery subject with 455 | // elements for selector1 456 | // and selector2, 457 | // and selector3, etc 458 | ``` 459 | 460 | You can also use a single array of selector strings 461 | 462 | ```js 463 | cy.getInOrder(['h1', 'h2', 'h3']) 464 | ``` 465 | 466 | Supports parent subject 467 | 468 | ```js 469 | cy.get('...').getInOrder('...', '...') 470 | ``` 471 | 472 | ### stable 473 | 474 | Sometimes you just want to wait until the element is stable. For example, if the element's text content does not change for N milliseconds, then we can consider the element to be `text` stable. 475 | 476 | ```js 477 | cy.get('#message').stable('text') 478 | // yields the element 479 | ``` 480 | 481 | Supported types: `text`, `value` (for input elements), `css`, and `element` (compares the element reference) 482 | 483 | You can control the quiet period (milliseconds), and pass the `log` and the `timeout` options 484 | 485 | ```js 486 | // stable for 500ms 487 | // without logging 488 | // with maximum retries duration of 6 seconds 489 | cy.get('#message').stable('text', 500, { log: false, timeout: 6_000 }) 490 | ``` 491 | 492 | When checking the CSS property to be stable, provide the name of the property: 493 | 494 | ```js 495 | // retries until the CSS animation finishes 496 | // and the background color is red 497 | cy.get('#message') 498 | .stable('css', 'background-color', 100) 499 | // yields the element 500 | .should('have.css', 'background-color', 'rgb(255, 0, 0)') 501 | ``` 502 | 503 | See [stable.cy.js](./cypress/e2e/stable/stable.cy.js) and [stable-css.cy.js](./cypress/e2e/stable-css/stable-css.cy.js) 504 | 505 | ### detaches 506 | 507 | **experimental** 508 | 509 | Retries until the element with the given selector detaches from DOM. 510 | 511 | ```js 512 | cy.contains('Click to re-render').click() 513 | cy.detaches('#list') 514 | ``` 515 | 516 | Sometimes the detachment can happen right with the action and the `cy.detaches(selector)` is _too late_. If you know the detachment might have already happened, you need to prepare for it by using an alias stored in the `Cypress.env` object: 517 | 518 | ```js 519 | cy.get('#name2').asEnv('name') 520 | cy.contains('Click to remove Joe').click() 521 | cy.detaches('@name') 522 | ``` 523 | 524 | The jQuery object will be stored inside the `Cypress.env` under the `name` property. 525 | 526 | See [detach.cy.js](./cypress/e2e/detach.cy.js) 527 | 528 | ### difference 529 | 530 | Computes an object/arrays of the difference with the current subject object/array. 531 | 532 | ```js 533 | cy.wrap({ name: 'Joe', age: 20 }) 534 | .difference({ name: 'Joe', age: 30 }) 535 | .should('deep.equal', { age: { actual: 20, expected: 30 } }) 536 | ``` 537 | 538 | You can use synchronous predicate functions to validate properties 539 | 540 | ```js 541 | // confirm the value of the "age" property 542 | // is larger than 15 543 | .difference({ name: 'Joe', age: (n) => n > 15 }) 544 | ``` 545 | 546 | Reports missing and extra properties. See [difference.cy.js](./cypress/e2e/difference.cy.js) 547 | 548 | **Note:** use `have.length` to validate the number of items in an array: 549 | 550 | ```js 551 | // let's check if there are 3 objects in the array 552 | // INSTEAD OF THIS 553 | .difference([Cypress._.object, Cypress._.object, Cypress._.object]) 554 | // USE AN ASSERTION 555 | .should('have.length', 3) 556 | ``` 557 | 558 | You can check each item in the array subject using values / predicates from the expected object. 559 | 560 | ```js 561 | // list of people objects 562 | cy.wrap(people) 563 | .difference({ 564 | name: Cypress._.isString, 565 | age: (age) => age > 1 && age < 100, 566 | }) 567 | .should('be.empty') 568 | ``` 569 | 570 | ### table 571 | 572 | 📝 to learn more about `cy.table` command, read the blog post [Test HTML Tables Using cy.table Query Command](https://glebbahmutov.com/blog/cy-table/). 573 | 574 | Extracts all cells from the current subject table. Yields a 2D array of strings. 575 | 576 | ```js 577 | cy.get('table').table() 578 | ``` 579 | 580 | You can slice the table to yield just a region `.table(x, y, w, h)` 581 | 582 | ![Table](./images/table.png) 583 | 584 | For example, you can get 2 by 2 subregion 585 | 586 | ```js 587 | cy.get('table') 588 | .table(0, 2, 2, 2) 589 | .should('deep.equal', [ 590 | ['Cary', '30'], 591 | ['Joe', '28'], 592 | ]) 593 | ``` 594 | 595 | See the spec [table.cy.js](./cypress/e2e/table.cy.js) for more examples. 596 | 597 | **Tip:** you can combine `cy.table` with `cy.map`, `cy.mapInvoke` to get the parts of the table. For example, the same 2x2 part of the table could be extracted with: 598 | 599 | ```js 600 | cy.get('table') 601 | .table() 602 | .invoke('slice', 2, 4) 603 | .mapInvoke('slice', 0, 2) 604 | .should('deep.equal', [ 605 | ['Cary', '30'], 606 | ['Joe', '28'], 607 | ]) 608 | ``` 609 | 610 | **Tip 2:** to get just the headings row, combine `.table` and `.its` queries 611 | 612 | ```js 613 | cy.get('table') 614 | .table(0, 0, 3, 1) 615 | .its(0) 616 | .should('deep.equal', ['Name', 'Age', 'Date (YYYY-MM-DD)']) 617 | ``` 618 | 619 | To get the last row, you could do: 620 | 621 | ```js 622 | cy.get('table').table().invoke('slice', -1).its(0) 623 | ``` 624 | 625 | To get the first column joined into a single array (instead of array of 1x1 arrays) 626 | 627 | ```js 628 | cy.get('table') 629 | .table(0, 1, 1) // skip the heading "Name" cell 630 | // combine 1x1 arrays into one array 631 | .invoke('flatMap', Cypress._.identity) 632 | .should('deep.equal', ['Dave', 'Cary', 'Joe', 'Anna']) 633 | ``` 634 | 635 | ### toPlainObject 636 | 637 | A query to convert special DOM objects into plain objects. For example, to convert `DOMStringMap` instance into a plain object compatible with `deep.equal` assertion we can do 638 | 639 | ```js 640 | cy.get('article') 641 | .should('have.prop', 'dataset') 642 | .toPlainObject() 643 | .should('deep.equal', { 644 | columns: '3', 645 | indexNumber: '12314', 646 | parent: 'cars', 647 | }) 648 | ``` 649 | 650 | By default uses JSON stringify and parse back. If you want to convert using `entries` and `fromEntries`, add an argument: 651 | 652 | ```js 653 | cy.wrap(new URLSearchParams(searchParams)).toPlainObject('entries') 654 | ``` 655 | 656 | ### invokeOnce 657 | 658 | In Cypress v12 `cy.invoke` became a query, which made working with asynchronous methods really unwieldy. The `cy.invokeOnce` is a return the old way of calling the method and yielding the resolved value. 659 | 660 | ```js 661 | cy.wrap(app) 662 | // app.fetchName is an asynchronous method 663 | // that returns a Promise 664 | .invokeOnce('fetchName') 665 | .should('equal', 'My App') 666 | ``` 667 | 668 | See the spec [invoke-once.cy.js](./cypress/e2e/invoke-once.cy.js) for more examples. 669 | 670 | ### read 671 | 672 | **Assertion** 673 | 674 | Checks the exact text match or regular expression for a single element or multiple ones 675 | 676 | ```js 677 | cy.get('#name').should('read', 'Joe Smith') 678 | cy.get('#ages').should('read', ['20', '35', '15']) 679 | 680 | // equivalent to 681 | cy.get('#name').map('innerText').should('deep.equal', ['Joe Smith']) 682 | cy.get('#ages') 683 | .map('innerText') 684 | .should('deep.equal', ['20', '35', '15']) 685 | ``` 686 | 687 | Using with regular expression or a mix of strings and regular expressions 688 | 689 | ```js 690 | cy.get('#name').should('read', /\sSmith$/) 691 | cy.get('#ages').should('read', [/^\d+$/, '35', '15']) 692 | ``` 693 | 694 | The assertion fails if the number of elements does not match the expected number of strings. 695 | 696 | ### possess 697 | 698 | **Assertion** 699 | 700 | Checks if the subject has the given property. Can check the property value. Yields the original subject 701 | 702 | ```js 703 | // check if the subject has the "name" property 704 | cy.wrap({ name: 'Joe' }).should('possess', 'name') 705 | // yields the { name: 'Joe' } object 706 | 707 | // check if the subject has the "name" property with the value "Joe" 708 | cy.wrap({ name: 'Joe' }).should('possess', 'name', 'Joe') 709 | // yields the { name: 'Joe' } object 710 | ``` 711 | 712 | The assertion supports deep object access using the dot notation 713 | 714 | ```js 715 | cy.wrap({ user: { name: 'Joe' } }).should( 716 | 'possess', 717 | 'user.name', 718 | 'Joe', 719 | ) 720 | ``` 721 | 722 | The assertion supports arrays using `[ ]` or simple dot notation 723 | 724 | ```js 725 | cy.wrap({ users: [{ name: 'Joe' }, { name: 'Jane' }] }) 726 | .should('possess', 'users[0].name', 'Joe') // [] notation 727 | .and('possess', 'users.1.name', 'Jane') // simple dot notation 728 | ``` 729 | 730 | You can also pass a predicate function instead of the value to check the property value against it. 731 | 732 | ```js 733 | const isDrinkingAge = (years) => years > 21 734 | cy.wrap({ age: 42 }).should('possess', 'age', isDrinkingAge) 735 | // yields the original subject 736 | ``` 737 | 738 | 📺 You can watch this assertion explained in the video [The Possess Assertion From cypress-map Plugin](https://youtu.be/HHxkL-BPyjA) 739 | 740 | ### unique 741 | 742 | Confirms the items in the current subject array are unique (using a `Set` to check) 743 | 744 | ```js 745 | cy.wrap([1, 2, 3]).should('be.unique') 746 | cy.wrap([1, 2, 2]).should('not.be.unique') 747 | ``` 748 | 749 | ## cy.invoke vs cy.map vs cy.mapInvoke 750 | 751 | Here are a few examples to clarify the different between the `cy.invoke`, `cy.map`, and `cy.mapInvoke` query commands, see [diff.cy.js](./cypress/e2e/diff.cy.js) 752 | 753 | ```js 754 | const list = ['apples', 'plums', 'bananas'] 755 | 756 | // cy.invoke 757 | cy.wrap(list) 758 | // calls ".sort()" on the list 759 | .invoke('sort') 760 | .should('deep.equal', ['apples', 'bananas', 'plums']) 761 | 762 | // cy.mapInvoke 763 | cy.wrap(list) 764 | // calls ".toUpperCase()" on every string in the list 765 | .mapInvoke('toUpperCase') 766 | .should('deep.equal', ['APPLES', 'PLUMS', 'BANANAS']) 767 | 768 | // cy.map 769 | const reverse = (s) => s.split('').reverse().join('') 770 | cy.wrap(list) 771 | // reverses each string in the list 772 | .map(reverse) 773 | .should('deep.equal', ['selppa', 'smulp', 'sananab']) 774 | // grabs the "length" property from each string 775 | .map('length') 776 | .should('deep.equal', [6, 5, 7]) 777 | ``` 778 | 779 | ## Misc 780 | 781 | ### mapChain 782 | 783 | I have added another useful command (not a query!) to this package. It allows you to process items in the array subject one by one via synchronous, asynchronous, or `cy` command functions. This is because the common solution to fetch items using `cy.each`, for example does not work: 784 | 785 | ```js 786 | // fetch the users from a list of ids 787 | // 🚨 DOES NOT WORK 788 | cy.get(ids).each(id => cy.request('/users/' + id)).then(users => ...) 789 | // Nope, the yielded "users" result is ... still the "ids" subject 790 | // ✅ CORRECT SOLUTION 791 | cy.get(ids).mapChain(id => cy.request('/users/' + id)).then(users => ...) 792 | ``` 793 | 794 | ## Types 795 | 796 | This package includes TypeScript command definitions for its custom commands in the file [commands/index.d.ts](./commands/index.d.ts). To use it from your JavaScript specs: 797 | 798 | ```js 799 | /// 800 | ``` 801 | 802 | If you are using TypeScript, include this module in your types list 803 | 804 | ```json 805 | { 806 | "compilerOptions": { 807 | "types": ["cypress", "cypress-map"] 808 | } 809 | } 810 | ``` 811 | 812 | ## The build process 813 | 814 | The source code is in the [src/commands](./src/commands/) folder. The build command produces ES5 code that goes into the `commands` folder (should not be checked into the source code control). The `package.json` in its NPM distribution includes `commands` plus the types from `src/commands/index.d.ts` file. 815 | 816 | ## See also 817 | 818 | - [cypress-should-really](https://github.com/bahmutov/cypress-should-really) has similar functional helpers for constructing the `should(callback)` function on the fly. 819 | 820 | **Note:** this module does not have `filter` method because Cypress API has query commands [cy.filter](https://on.cypress.io/filter) and [cy.invoke](https://on.cypress.io/invoke) that you can use to filter elements in a jQuery object or items in an array. See the examples in the [filter.cy.js](./cypress/e2e/filter.cy.js) spec. 📺 See video [Filter Elements And Items With Retries](https://youtu.be/70kRnoMuzds). 821 | 822 | ## Small print 823 | 824 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2022 825 | 826 | - [@bahmutov](https://twitter.com/bahmutov) 827 | - [glebbahmutov.com](https://glebbahmutov.com) 828 | - [blog](https://glebbahmutov.com/blog) 829 | - [videos](https://www.youtube.com/glebbahmutov) 830 | - [presentations](https://slides.com/bahmutov) 831 | - [cypress.tips](https://cypress.tips) 832 | - [Cypress Tips & Tricks Newsletter](https://cypresstips.substack.com/) 833 | - [my Cypress courses](https://cypress.tips/courses) 834 | 835 | License: MIT - do anything with the code, but don't blame me if it does not work. 836 | 837 | Support: if you find any problems with this module, email / tweet / 838 | [open issue](https://github.com/bahmutov/cypress-map/issues) on Github 839 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | // https://github.com/bahmutov/cypress-split 3 | const cypressSplit = require('cypress-split') 4 | 5 | module.exports = defineConfig({ 6 | e2e: { 7 | // baseUrl, etc 8 | viewportHeight: 200, 9 | viewportWidth: 200, 10 | supportFile: false, 11 | setupNodeEvents(on, config) { 12 | // implement node event listeners here 13 | // and load any plugins that require the Node environment 14 | cypressSplit(on, config) 15 | // IMPORTANT: return the config object 16 | return config 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /cypress/dataset.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | All about electric cards 4 |
5 | 14 | 15 | -------------------------------------------------------------------------------- /cypress/detach.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Santo
4 | 5 |
6 |
Joe
7 |
8 | 9 | 10 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /cypress/e2e/apply.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../src/commands' 5 | 6 | const double = (n) => n * 2 7 | const div = (a, b) => a / b 8 | 9 | describe('apply', () => { 10 | it('applies the given callback to the subject', () => { 11 | cy.wrap(100).apply(double).should('equal', 200) 12 | }) 13 | 14 | it('applies with arguments (subject is last)', () => { 15 | cy.wrap(100).apply(div, 1000).should('equal', 10) 16 | cy.wrap(2).apply(Cypress._.add, 4).should('equal', 6) 17 | cy.wrap(8).apply(Cypress._.subtract, 4).should('equal', -4) 18 | }) 19 | }) 20 | 21 | describe('applyRight', () => { 22 | it('applies the given callback to the subject', () => { 23 | cy.wrap(100).applyRight(double).should('equal', 200) 24 | }) 25 | 26 | it('applies with arguments (subject is first)', () => { 27 | cy.wrap(100).applyRight(div, 1000).should('equal', 0.1) 28 | cy.wrap(8).applyRight(Cypress._.subtract, 4).should('equal', 4) 29 | // same as 30 | cy.wrap(8) 31 | .apply((subject) => Cypress._.subtract(subject, 4)) 32 | .should('equal', 4) 33 | }) 34 | }) 35 | 36 | describe('applyToFirst', () => { 37 | it('applies the given callback to the first item', () => { 38 | cy.wrap([100, 200]).applyToFirst(double).should('equal', 200) 39 | }) 40 | 41 | it('applies with arguments (subject is last)', () => { 42 | cy.wrap([100]).applyToFirst(div, 1000).should('equal', 10) 43 | cy.wrap([2]).applyToFirst(Cypress._.add, 4).should('equal', 6) 44 | cy.wrap([8]) 45 | .applyToFirst(Cypress._.subtract, 4) 46 | .should('equal', -4) 47 | }) 48 | 49 | it('applies to the first element', () => { 50 | cy.wrap(Cypress.$('
100
200
')) 51 | .applyToFirst((base, el) => parseInt(el.innerText, base), 10) 52 | .should('equal', 100) 53 | }) 54 | }) 55 | 56 | describe('applyToFirstRight', () => { 57 | it('applies the given callback to the subject', () => { 58 | cy.wrap([100]).applyToFirstRight(double).should('equal', 200) 59 | }) 60 | 61 | it('applies with arguments (subject is first)', () => { 62 | cy.wrap([100]).applyToFirstRight(div, 1000).should('equal', 0.1) 63 | cy.wrap([8]) 64 | .applyToFirstRight(Cypress._.subtract, 4) 65 | .should('equal', 4) 66 | // same as 67 | cy.wrap([8]) 68 | .applyToFirst((subject) => Cypress._.subtract(subject, 4)) 69 | .should('equal', 4) 70 | }) 71 | 72 | it('applies to the first element', () => { 73 | cy.wrap(Cypress.$('
100
200
')) 74 | .applyToFirstRight( 75 | (el, base) => parseInt(el.innerText, base), 76 | 10, 77 | ) 78 | .should('equal', 100) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /cypress/e2e/as-env.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('does not have saved value yet', () => { 7 | expect(Cypress.env('greeting'), 'greeting is undefined').to.be 8 | .undefined 9 | }) 10 | 11 | it('saves value in this test', () => { 12 | cy.wrap('hello, world') 13 | .asEnv('greeting') 14 | // the value is yielded to the next command 15 | .should('equal', 'hello, world') 16 | }) 17 | 18 | it('saved value is available in this test', () => { 19 | expect(Cypress.env('greeting'), 'greeting').to.equal('hello, world') 20 | }) 21 | 22 | it('saved value is available in this test too', () => { 23 | expect(Cypress.env('greeting'), 'greeting').to.equal('hello, world') 24 | }) 25 | 26 | it('retries like all queries', () => { 27 | const person = {} 28 | setTimeout(() => { 29 | person.name = 'Joe' 30 | }, 1000) 31 | cy.wrap(person) 32 | .asEnv('person') 33 | .its('name') 34 | .should('equal', 'Joe') 35 | .then(() => { 36 | expect(Cypress.env('person'), 'person').to.have.property( 37 | 'name', 38 | 'Joe', 39 | ) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /cypress/e2e/assertions/possess.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../../src/commands' 5 | 6 | describe('have.property assertion', () => { 7 | it('yields the property value', () => { 8 | cy.wrap({ foo: 'bar' }) 9 | .should('have.property', 'foo') 10 | .should('equal', 'bar') 11 | }) 12 | }) 13 | 14 | describe('should possess assertion', () => { 15 | it('checks if the subject possesses a property', () => { 16 | cy.wrap({ foo: 'bar' }) 17 | .should('possess', 'foo') 18 | .and('not.possess', 'fooz') 19 | }) 20 | 21 | it('yields the subject', () => { 22 | cy.wrap({ foo: 'bar' }) 23 | .should('possess', 'foo') 24 | .should('deep.equal', { foo: 'bar' }) 25 | }) 26 | 27 | it('checks the property value', () => { 28 | cy.wrap({ foo: 'bar' }) 29 | .should('possess', 'foo', 'bar') 30 | // still yields the original subject 31 | .should('deep.equal', { foo: 'bar' }) 32 | 33 | cy.wrap({ foo: 42 }).should('possess', 'foo', 42) 34 | }) 35 | 36 | it('checks the property value using not', () => { 37 | cy.wrap({ foo: 'bar' }).should('not.possess', 'foo', 'BAR') 38 | }) 39 | 40 | it('checks the property presence using not and value', () => { 41 | cy.wrap({ foo: 'bar' }).should( 42 | 'not.possess', 43 | 'some-other-name', 44 | 'BAR', 45 | ) 46 | }) 47 | 48 | it('supports nested properties', () => { 49 | const person = { name: { first: 'Joe' } } 50 | cy.wrap(person) 51 | .should('possess', 'name.first', 'Joe') 52 | // still yields the original subject 53 | .should('deep.equal', person) 54 | }) 55 | 56 | it('supports arrays', () => { 57 | const counts = [1, 2, 3] 58 | cy.wrap(counts) 59 | .should('possess', '1', 2) 60 | // still yields the original subject 61 | .should('deep.equal', counts) 62 | .should('possess', 'length', 3) 63 | }) 64 | 65 | it('supports mixing arrays and objects', () => { 66 | cy.wrap([ 67 | { sum: 42 }, 68 | { sum: 101 }, 69 | { sum: { errors: ['Invalid operation'] } }, 70 | ]).should('possess', '2.sum.errors.0', 'Invalid operation') 71 | }) 72 | 73 | it('supports array bracket notation', () => { 74 | cy.wrap([ 75 | { sum: 42 }, 76 | { sum: 101 }, 77 | { sum: { errors: ['Invalid operation'] } }, 78 | ]).should('possess', '[2].sum.errors[0]', 'Invalid operation') 79 | }) 80 | 81 | // unskip to see the thrown error 82 | it.skip('throws an error', () => { 83 | cy.wrap({ foo: 'bar' }) 84 | // @ts-ignore 85 | .should('possess', 'foo', 'bar', 123) 86 | .should('deep.equal', { foo: 'bar' }) 87 | }) 88 | 89 | context('with a predicate', () => { 90 | it('checks the property value against the predicate function', () => { 91 | cy.wrap({ foo: 'bar' }) 92 | .should('possess', 'foo', (val) => val === 'bar') 93 | // keeps the subject 94 | .and('deep.equal', { foo: 'bar' }) 95 | }) 96 | 97 | it('negates the predicate function', () => { 98 | const isDrinkingAge = (years) => years > 21 99 | cy.wrap({ age: 42 }).should('possess', 'age', isDrinkingAge) 100 | cy.wrap({ age: 1 }).should('not.possess', 'age', isDrinkingAge) 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /cypress/e2e/assertions/read.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../../src/commands' 5 | 6 | it('compares text', () => { 7 | cy.visit('cypress/index.html') 8 | cy.get('li').first().should('read', 'first') 9 | cy.get('li').should('read', ['first', 'second', 'third']) 10 | // passes after delay 11 | cy.get('li').should('read', [ 12 | 'first', 13 | 'second', 14 | 'third', 15 | 'fourth', 16 | 'fifth', 17 | ]) 18 | }) 19 | 20 | it('supports a single regular expression', () => { 21 | cy.visit('cypress/index.html') 22 | cy.get('li').first().should('read', /FIRST/i) 23 | }) 24 | 25 | it('supports a mixture of strings and regular expressions', () => { 26 | cy.visit('cypress/index.html') 27 | cy.get('li').should('read', ['first', /^sec/, 'third']) 28 | }) 29 | 30 | it( 31 | 'fails when the number of elements does not match', 32 | { defaultCommandTimeout: 2000 }, 33 | () => { 34 | cy.on('fail', (err) => { 35 | console.log(err.message) 36 | const validFailureMessage = 37 | 'Timed out retrying after 2000ms: expected first, second, third, fourth, fifth to read first, second' 38 | if (err.message !== validFailureMessage) { 39 | throw err 40 | } 41 | }) 42 | 43 | cy.visit('cypress/index.html') 44 | // try using the wrong number of elements 45 | cy.get('li').should('read', ['first', 'second']) 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /cypress/e2e/at.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('cy.eq yields the jQuery object', () => { 7 | cy.visit('cypress/index.html') 8 | cy.get('.matching') 9 | .eq(1) 10 | .should('satisfy', Cypress.dom.isJquery) 11 | .invoke('text') 12 | .should('equal', 'third') 13 | cy.get('.matching') 14 | .eq(2) 15 | .should('satisfy', Cypress.dom.isJquery) 16 | .and('have.length.above', 0) 17 | .invoke('text') 18 | .should('equal', 'fourth') 19 | }) 20 | 21 | it('cy.at yields the DOM element', () => { 22 | cy.visit('cypress/index.html') 23 | cy.get('.matching') 24 | .at(1) 25 | .should('satisfy', Cypress.dom.isElement) 26 | .its('innerText') 27 | .should('equal', 'third') 28 | cy.get('.matching') 29 | .at(2) 30 | .should('satisfy', Cypress.dom.isElement) 31 | .its('innerText') 32 | .should('equal', 'fourth') 33 | }) 34 | 35 | it('cy.at yields the last DOM element for index -1', () => { 36 | cy.visit('cypress/index.html') 37 | cy.get('#items li').at(-1).its('innerText').should('equal', 'fifth') 38 | }) 39 | 40 | it('cy.at yields an element in the array', () => { 41 | cy.wrap([1, 2, 3]).at(2).should('equal', 3) 42 | }) 43 | 44 | it('cy.at the last element of the array for -1', () => { 45 | cy.wrap([1, 2, 3]).at(-1).should('equal', 3) 46 | cy.wrap([1, 2, 3]).at(-2).should('equal', 2) 47 | }) 48 | -------------------------------------------------------------------------------- /cypress/e2e/chain-with-assertions.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | const double = (n) => n * 2 5 | 6 | // @ts-ignore 7 | Cypress.Commands.addQuery('apply', (fn) => (n) => fn(n)) 8 | 9 | // https://github.com/cypress-io/cypress/issues/25134 10 | it.skip('fails to retry the cy.its', () => { 11 | const list = [] 12 | cy.wrap(list) 13 | .its(0) // first item 14 | // this assertion breaks the query chain retries 15 | // it never "sees" the new number 5 16 | // because it never retries cy.its above 17 | .should('be.a', 'number') 18 | .apply(double) 19 | .should('equal', 10) 20 | 21 | setTimeout(() => { 22 | list[0] = 1 23 | }, 1000) 24 | 25 | setTimeout(() => { 26 | list[0] = 5 27 | }, 2000) 28 | }) 29 | 30 | it('retries queries with assertions', () => { 31 | const list = [] 32 | cy.wrap(list) 33 | // several queries (without assertion) 34 | .its(0) // first item 35 | .apply(double) 36 | .should('equal', 10) 37 | 38 | setTimeout(() => { 39 | list[0] = 1 40 | }, 1000) 41 | 42 | setTimeout(() => { 43 | list[0] = 5 44 | }, 2000) 45 | }) 46 | -------------------------------------------------------------------------------- /cypress/e2e/detach.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | describe('detach', () => { 8 | beforeEach(() => { 9 | cy.visit('cypress/detach.html') 10 | }) 11 | 12 | it('removes the element from the DOM', () => { 13 | cy.contains('#name', 'Santo').should( 14 | 'satisfy', 15 | Cypress.dom.isDetached, 16 | ) 17 | }) 18 | 19 | it('removes after click', () => { 20 | // grab the initial element to prepare 21 | cy.get('#name2').then(($el) => { 22 | cy.contains('Click to remove Joe').click() 23 | // confirm the old element is gone 24 | cy.wrap(null).should(() => { 25 | expect($el[0], 'element is gone').to.satisfy( 26 | Cypress.dom.isDetached, 27 | ) 28 | }) 29 | // the new element should be quickly there 30 | cy.contains('#name2', 'Anna', { timeout: 0 }) 31 | }) 32 | }) 33 | 34 | it('removes after click (query)', () => { 35 | cy.contains('Click to remove Joe').click() 36 | cy.detaches('#name2') 37 | // confirm the old element is gone 38 | // the new element should be quickly there 39 | cy.contains('#name2', 'Anna', { timeout: 0 }) 40 | }) 41 | 42 | it('removes after click (split query)', () => { 43 | cy.get('#name2').asEnv('name') 44 | cy.contains('Click to remove Joe').click() 45 | cy.detaches('@name') 46 | // confirm the old element is gone 47 | // the new element should be quickly there 48 | cy.contains('#name2', 'Anna', { timeout: 0 }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /cypress/e2e/diff.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | // this spec shows the difference between 7 | // cy.invoke, cy.mapInvoke, and cy.map 8 | 9 | it('invokes a method on the subject', () => { 10 | const list = ['apples', 'plums', 'bananas'] 11 | cy.wrap(list) 12 | // calls ".sort()" on the list 13 | .invoke('sort') 14 | .should('deep.equal', ['apples', 'bananas', 'plums']) 15 | }) 16 | 17 | it('invokes a method on the items in the subject', () => { 18 | const list = ['apples', 'plums', 'bananas'] 19 | cy.wrap(list) 20 | // calls ".toUpperCase()" on every string in the list 21 | .mapInvoke('toUpperCase') 22 | .should('deep.equal', ['APPLES', 'PLUMS', 'BANANAS']) 23 | }) 24 | 25 | it('maps each item by running it through the callback or property', () => { 26 | const list = ['apples', 'plums', 'bananas'] 27 | const reverse = (s) => s.split('').reverse().join('') 28 | cy.wrap(list) 29 | // reverses each string in the list 30 | .map(reverse) 31 | .should('deep.equal', ['selppa', 'smulp', 'sananab']) 32 | // grabs the "length" property from each string 33 | .map('length') 34 | .should('deep.equal', [6, 5, 7]) 35 | }) 36 | -------------------------------------------------------------------------------- /cypress/e2e/difference.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | chai.config.truncateThreshold = 300 7 | 8 | describe('objects', () => { 9 | it('compares two different objects', () => { 10 | cy.wrap({ name: 'Joe', age: 20 }) 11 | .difference({ name: 'Joe', age: 30 }) 12 | .should('deep.equal', { age: { actual: 20, expected: 30 } }) 13 | }) 14 | 15 | it('compares two identical objects', () => { 16 | cy.wrap({ name: 'Joe', age: 20 }) 17 | .difference({ name: 'Joe', age: 20 }) 18 | .should('deep.equal', {}) 19 | }) 20 | 21 | it('retries when comparing', () => { 22 | const p = { name: 'Joe' } 23 | setTimeout(() => { 24 | p.age = 20 25 | }, 200) 26 | cy.wrap(p) 27 | .difference({ name: 'Joe', age: 20 }) 28 | .should('deep.equal', {}) 29 | }) 30 | 31 | it('reports a missing property', () => { 32 | cy.wrap({ name: 'Joe', age: 20 }) 33 | .difference({ name: 'Joe', age: 20, extra: true }) 34 | .should('deep.equal', { 35 | extra: { missing: true, expected: true }, 36 | }) 37 | }) 38 | 39 | it('reports extra property', () => { 40 | cy.wrap({ name: 'Joe', age: 20, extra: true }) 41 | .difference({ name: 'Joe', age: 20 }) 42 | .should('deep.equal', { 43 | extra: { extra: true, actual: true }, 44 | }) 45 | }) 46 | }) 47 | 48 | describe('predicates', () => { 49 | it('allows to use custom predicates', () => { 50 | cy.wrap({ name: 'Joe', age: 20 }) 51 | .difference({ name: 'Joe', age: (n) => n > 15 }) 52 | .should('deep.equal', {}) 53 | }) 54 | 55 | it('checks the number', () => { 56 | cy.wrap({ name: 'Joe', age: 20 }) 57 | .difference({ name: 'Joe', age: Cypress._.isNumber }) 58 | .should('deep.equal', {}) 59 | }) 60 | }) 61 | 62 | describe('compares two arrays', () => { 63 | it('compares two equal arrays', () => { 64 | cy.wrap([1, 2, 3]).difference([1, 2, 3]).should('be.empty') 65 | }) 66 | 67 | it('compares two different arrays', () => { 68 | cy.wrap([1, 2, 3]) 69 | .difference([1, 2, 4]) 70 | .should('deep.equal', { 2: { actual: 3, expected: 4 } }) 71 | }) 72 | 73 | it('compares two arrays that become equal', () => { 74 | const list = [] 75 | cy.wrap(list).difference([1, 2, 3]).should('be.empty') 76 | setTimeout(() => { 77 | list.push(1, 2, 3) 78 | }, 200) 79 | }) 80 | 81 | it('compares two arrays with predicates', () => { 82 | const list = [] 83 | cy.wrap(list) 84 | .difference([ 85 | Cypress._.isNumber, 86 | (x) => x === 'foo', 87 | (x) => x === 'bar', 88 | ]) 89 | .should('be.empty') 90 | setTimeout(() => { 91 | list.push(1) 92 | }, 200) 93 | setTimeout(() => { 94 | list.push('foo') 95 | }, 400) 96 | setTimeout(() => { 97 | list.push('bar') 98 | }, 600) 99 | }) 100 | 101 | it('checks 3 strings in an array', () => { 102 | const list = [] 103 | cy.wrap(list) 104 | .difference([ 105 | Cypress._.isString, 106 | Cypress._.isString, 107 | Cypress._.isString, 108 | ]) 109 | .should('be.empty') 110 | setTimeout(() => { 111 | list.push('foo', 'bar', 'baz') 112 | }, 600) 113 | }) 114 | 115 | it('checks each item using deep equal', () => { 116 | const list = [] 117 | cy.wrap(list) 118 | .difference([ 119 | { name: 'foo', age: 20 }, 120 | { name: 'bar', age: 30 }, 121 | ]) 122 | .should('be.empty') 123 | setTimeout(() => { 124 | list.push({ name: 'foo', age: 20 }) 125 | }, 200) 126 | setTimeout(() => { 127 | list.push({ name: 'bar', age: 30 }) 128 | }, 400) 129 | }) 130 | }) 131 | 132 | describe('check items in an array using schema object', () => { 133 | it('checks each item against predicates', () => { 134 | const list = [] 135 | cy.wrap(list) 136 | .difference({ 137 | name: Cypress._.isString, 138 | age: (age) => age > 15, 139 | }) 140 | .should('be.empty') 141 | setTimeout(() => { 142 | list.push({ name: 'foo', age: 20 }) 143 | }, 200) 144 | setTimeout(() => { 145 | list.push({ name: 'bar', age: 30 }) 146 | }, 400) 147 | }) 148 | 149 | it('checks each item against predicates (different value)', () => { 150 | const list = [ 151 | { 152 | name: 'Joe', 153 | age: 20, 154 | }, 155 | { 156 | name: 'Anna', 157 | age: 20, 158 | }, 159 | ] 160 | cy.wrap(list) 161 | .difference({ 162 | name: 'Joe', 163 | age: (age) => age > 15, 164 | }) 165 | .should('deep.equal', { 166 | 1: { 167 | name: { actual: 'Anna', expected: 'Joe' }, 168 | }, 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /cypress/e2e/elements.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | chai.config.truncateThreshold = 300 7 | 8 | describe('elements', () => { 9 | it('loads the list of elements', () => { 10 | cy.visit('cypress/list.html') 11 | cy.elements('#tasks li', '.name', '.k').should('deep.equal', [ 12 | ['Item A', '1'], 13 | ['Item B', '2'], 14 | ['Item C', '3'], 15 | ['Item D', '4'], 16 | ]) 17 | }) 18 | 19 | it('loads the list of elements with order', () => { 20 | cy.visit('cypress/list.html') 21 | cy.elements('#tasks li', '.k', '.name').should('deep.equal', [ 22 | ['1', 'Item A'], 23 | ['2', 'Item B'], 24 | ['3', 'Item C'], 25 | ['4', 'Item D'], 26 | ]) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/e2e/filter-table.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | chai.config.truncateThreshold = 500 8 | 9 | it( 10 | 'controls the network data and uses cy.table', 11 | { viewportHeight: 600, viewportWidth: 600 }, 12 | () => { 13 | cy.intercept('/people', { 14 | fixture: 'people.json', 15 | delay: 1000, 16 | }).as('people') 17 | cy.visit('cypress/e2e/filter-table/index.html') 18 | cy.get('#people tbody') 19 | // do not specify height 20 | .table(0, 0, 1) 21 | .apply(Cypress._.flatten) 22 | .should('deep.equal', ['Peter', 'Pete', 'Mary', 'Mary-Ann']) 23 | cy.get('input#by-name').type('Mary') 24 | cy.get('#people tbody') 25 | // do not specify height 26 | .table(0, 0, 1) 27 | .apply(Cypress._.flatten) 28 | .should('deep.equal', ['Mary', 'Mary-Ann']) 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /cypress/e2e/filter-table/filter.js: -------------------------------------------------------------------------------- 1 | const initialList = [ 2 | { 3 | name: 'Joe Z', 4 | date: '1990-02-25', 5 | age: 20, 6 | }, 7 | { 8 | name: 'Anna', 9 | date: '2010-03-26', 10 | age: 37, 11 | }, 12 | { 13 | name: 'Dave', 14 | date: '1997-12-23', 15 | age: 25, 16 | }, 17 | { 18 | name: 'Joseph', 19 | date: '2001-01-24', 20 | age: 30, 21 | }, 22 | { 23 | name: 'Jonathan', 24 | date: '2004-02-01', 25 | age: 30, 26 | }, 27 | ] 28 | 29 | let list = initialList 30 | let filtered = list 31 | let nameFilter 32 | 33 | // utility methods 34 | const itemToRow = (item) => 35 | `${item.name}${item.date}${item.age}` 36 | const listToHtml = (list) => list.map(itemToRow).join('\n') 37 | 38 | // the main "render" method 39 | const render = () => { 40 | if (nameFilter) { 41 | filtered = list.filter((item) => item.name.includes(nameFilter)) 42 | } else { 43 | filtered = list 44 | } 45 | document.getElementById('people-data').innerHTML = listToHtml(filtered) 46 | } 47 | 48 | // set the initial table 49 | fetch('/people').then((r) => { 50 | if (r.ok) { 51 | r.json().then((data) => { 52 | list = data 53 | render() 54 | }) 55 | } else { 56 | // use the default data 57 | list = initialList 58 | render() 59 | } 60 | }) 61 | 62 | document.getElementById('by-name').addEventListener('input', (e) => { 63 | nameFilter = e.target.value 64 | setTimeout(() => { 65 | render() 66 | }, 1000) 67 | }) 68 | -------------------------------------------------------------------------------- /cypress/e2e/filter-table/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Filter Table 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
NameDateAge
23 |
24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /cypress/e2e/filter-table/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | height: 100vh; 7 | margin: 0; 8 | } 9 | 10 | table { 11 | border-spacing: 1px; 12 | } 13 | 14 | table td { 15 | border: 1px solid black; 16 | padding: 5px; 17 | } 18 | 19 | #sort-by-date { 20 | margin: 0px 10px; 21 | } 22 | 23 | .buttons { 24 | margin-top: 10px; 25 | display: flex; 26 | } 27 | -------------------------------------------------------------------------------- /cypress/e2e/filter.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | chai.config.truncateThreshold = 500 8 | 9 | // 📺 watch these examples explained 10 | // in the video "Filter Elements And Items With Retries" 11 | // https://youtu.be/70kRnoMuzds 12 | 13 | describe('Filter examples', () => { 14 | it('filters array items', () => { 15 | const people = [ 16 | { 17 | name: 'Joe', 18 | }, 19 | { 20 | name: 'Mary', 21 | }, 22 | ] 23 | // simulate a dynamic list by adding new records 24 | setTimeout(() => { 25 | people.push( 26 | { 27 | name: 'Ann', 28 | }, 29 | { 30 | name: 'Kent', 31 | }, 32 | ) 33 | }, 1000) 34 | 35 | cy.wrap(people) 36 | // keep only the even items 37 | // the cy.invoke query command will be retried 38 | // https://on.cypress.io/invoke 39 | // Note: we cannot use cy.filter because the subject is not jQuery 40 | .invoke('filter', (x, k) => k % 2 === 0) 41 | .should('deep.equal', [ 42 | { 43 | name: 'Joe', 44 | }, 45 | { 46 | name: 'Ann', 47 | }, 48 | ]) 49 | }) 50 | 51 | it( 52 | 'filters elements in a jQuery object', 53 | { viewportHeight: 600, viewportWidth: 600 }, 54 | () => { 55 | cy.intercept('/people', { 56 | fixture: 'people.json', 57 | delay: 1000, 58 | }).as('people') 59 | cy.visit('cypress/e2e/filter-table/index.html') 60 | cy.get('table tbody tr') 61 | // cy.filter query command is retried 62 | // https://on.cypress.io/filter 63 | .filter((k, el) => k % 2 === 0) 64 | .find('td:first') // query 65 | .map('innerText') // query 66 | .should('deep.equal', ['Peter', 'Mary']) 67 | }, 68 | ) 69 | }) 70 | -------------------------------------------------------------------------------- /cypress/e2e/find-one.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | describe('findOne', () => { 8 | it('yields the element using callback fn', () => { 9 | const values = [] 10 | setTimeout(() => { 11 | values.push(1) 12 | values.push(2) 13 | values.push(3) 14 | }, 1000) 15 | cy.wrap(values) 16 | .findOne((n) => n === 3) 17 | .should('equal', 3) 18 | }) 19 | 20 | it('shows the callback function name if any', () => { 21 | const values = [1, 2] 22 | setTimeout(() => { 23 | values.push(3) 24 | }, 1000) 25 | cy.wrap(values) 26 | .findOne(function is3(n) { 27 | return n === 3 28 | }) 29 | .should('equal', 3) 30 | }) 31 | 32 | it('shows the callback arrow function name if any', () => { 33 | const values = [1, 2] 34 | const equals3 = (n) => n === 3 35 | setTimeout(() => { 36 | values.push(3) 37 | }, 1000) 38 | cy.wrap(values).findOne(equals3).should('equal', 3) 39 | }) 40 | 41 | it('finds using property', () => { 42 | const values = [{ name: 'Joe' }] 43 | setTimeout(() => { 44 | values.push({ name: 'Anna' }) 45 | }, 1000) 46 | cy.wrap(values).findOne({ name: 'Anna' }).should('exist') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /cypress/e2e/get-in-order.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('queries the elements in the given order', () => { 7 | cy.visit('cypress/index.html') 8 | cy.getInOrder('li:contains("second")', 'li:contains("first")') 9 | .map('innerText') 10 | .should('deep.equal', ['second', 'first']) 11 | }) 12 | 13 | it('retries each selector', () => { 14 | cy.visit('cypress/index.html') 15 | cy.getInOrder( 16 | 'li:contains("fifth")', 17 | 'li:contains("first")', 18 | 'li:contains("third")', 19 | ) 20 | .should('have.length', 3) 21 | .map('innerText') 22 | .should('deep.equal', ['fifth', 'first', 'third']) 23 | }) 24 | 25 | it('OR selector', () => { 26 | cy.visit('cypress/index.html') 27 | // the OR selector is a comma and it returns 28 | // elements in the order they appear in the DOM 29 | // NOT in the order of the selectors 30 | cy.get( 31 | 'li:contains("fifth"),li:contains("first"),li:contains("third")', 32 | ) 33 | .should('have.length', 3) 34 | .map('innerText') 35 | // the order is the same as in the DOM 36 | .should('deep.equal', ['first', 'third', 'fifth']) 37 | }) 38 | 39 | // https://github.com/bahmutov/cypress-map/issues/144 40 | it('spreads an array of strings', () => { 41 | cy.visit('cypress/index.html') 42 | const selectors = [ 43 | 'li:contains("fifth")', 44 | 'li:contains("first")', 45 | 'li:contains("third")', 46 | ] 47 | cy.getInOrder(selectors) 48 | .should('have.length', 3) 49 | .map('innerText') 50 | .should('deep.equal', ['fifth', 'first', 'third']) 51 | }) 52 | 53 | // https://github.com/bahmutov/cypress-map/issues/219 54 | it( 55 | 'uses the current subject', 56 | { viewportWidth: 500, viewportHeight: 500 }, 57 | () => { 58 | cy.visit('cypress/e2e/filter-table/index.html') 59 | cy.get('table tbody tr').should('have.length', 5) 60 | cy.get('table tbody tr:eq(2)') 61 | .should('include.text', 'Dave') 62 | // get the age and the name cells 63 | .getInOrder('td:eq(2)', 'td:eq(0)') 64 | .map('innerText') 65 | .should('deep.equal', ['25', 'Dave']) 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /cypress/e2e/import-library.js: -------------------------------------------------------------------------------- 1 | import '../../commands' 2 | -------------------------------------------------------------------------------- /cypress/e2e/instance.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('checks instance of array', () => { 7 | expect([1, 2, 3]).to.be.an.instanceOf(Array) 8 | }) 9 | 10 | it('works before cy.map', () => { 11 | cy.wrap(['one', 'a', 'four']).should('be.an.instanceOf', Array) 12 | }) 13 | 14 | it('maps to an array', () => { 15 | cy.wrap(['one', 'a', 'four']) 16 | .should('be.an.instanceOf', Array) 17 | .map('length') 18 | .should('deep.equal', [3, 1, 4]) 19 | .and('be.an', 'array') 20 | .and('be.an.instanceOf', Array) 21 | }) 22 | -------------------------------------------------------------------------------- /cypress/e2e/invoke-first.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | describe('cy.invokeFirst', () => { 8 | expect('invokeFirst' in cy).to.be.true 9 | 10 | it('calls the given method on the first item', () => { 11 | cy.wrap( 12 | [ 13 | { 14 | getName: cy.stub().as('first').returns('Joe'), 15 | }, 16 | { 17 | getName: cy.stub().as('second').returns('Mary'), 18 | }, 19 | ], 20 | { log: false }, 21 | ) 22 | .invokeFirst('getName') 23 | .should('equal', 'Joe') 24 | 25 | cy.log('**confirm the first item was called**') 26 | cy.get('@first').should('have.been.calledOnce') 27 | cy.get('@second').should('not.have.been.called') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /cypress/e2e/invoke-once.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | describe('invoke vs invokeOnce', () => { 8 | expect('invokeOnce' in cy).to.be.true 9 | 10 | const app = { 11 | fetchName() { 12 | return new Promise((resolve) => { 13 | setTimeout(() => { 14 | resolve('My App') 15 | }, 1000) 16 | }) 17 | }, 18 | 19 | add(a, b) { 20 | return Promise.resolve(a + b) 21 | }, 22 | } 23 | 24 | it('cy.then yields the resolved value', () => { 25 | // call the method ourselves from "cy.then" 26 | cy.wrap(app) 27 | .then((app) => app.fetchName()) 28 | .should('equal', 'My App') 29 | }) 30 | 31 | it('cy.invoke yields the promise', () => { 32 | cy.wrap(app) 33 | .invoke('fetchName') 34 | .should('satisfy', (x) => typeof x.then === 'function') 35 | // then we can check the resolved value 36 | .then((s) => expect(s).to.equal('My App')) 37 | 38 | // we can use a truthy assertion to "wait" for the promise 39 | cy.wrap(app) 40 | .invoke('fetchName') 41 | .should('be.ok') 42 | // and then we can check the resolved value 43 | .then((s) => expect(s).to.equal('My App')) 44 | }) 45 | 46 | it('calls the method and yields the resolved value', () => { 47 | cy.wrap(app).invokeOnce('fetchName').should('equal', 'My App') 48 | }) 49 | 50 | it('passes method arguments', () => { 51 | cy.wrap(app).invokeOnce('add', 2, 3).should('equal', 5) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /cypress/e2e/json-attribute/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
First person
4 | 5 | 6 | -------------------------------------------------------------------------------- /cypress/e2e/json-attribute/spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | import '../../../src/commands' 4 | 5 | // implementation similar to "Json data attribute" recipe 6 | // from https://glebbahmutov.com/cypress-examples 7 | 8 | it('compares the parsed data attribute object', () => { 9 | cy.visit('cypress/e2e/json-attribute/index.html') 10 | // grab the element's attribute "data-field" 11 | // convert it into a JSON object 12 | // and grab its "age" property -> should be equal 10 13 | cy.get('#person') 14 | .invoke('attr', 'data-field') 15 | .apply(JSON.parse) 16 | .its('age') 17 | .should('equal', 10) 18 | }) 19 | -------------------------------------------------------------------------------- /cypress/e2e/log-examples.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | chai.config.truncateThreshold = 200 7 | 8 | describe('cy.print', () => { 9 | it('prints the user age', () => { 10 | cy.visit('cypress/log-examples.html') 11 | cy.contains('#age', /\d+/) 12 | .invoke('text') 13 | .print('my age is %d') 14 | .then(Number) 15 | // what's my age again? 16 | .should('be.within', 1, 99) 17 | }) 18 | 19 | it('prints the object', () => { 20 | cy.intercept('/users', { 21 | body: [ 22 | { name: 'Joe', age: 1, role: 'student' }, 23 | { name: 'Ann', age: 2, role: 'student' }, 24 | { name: 'Mary', age: 3, role: 'student' }, 25 | ], 26 | }).as('users') 27 | cy.visit('cypress/log-examples.html') 28 | cy.wait('@users').its('response.body').print().should('be.an', 'array') 29 | }) 30 | 31 | it('prints the length and the first object', () => { 32 | cy.intercept('/users', { 33 | body: [ 34 | { name: 'Joe', age: 1, role: 'student' }, 35 | { name: 'Ann', age: 2, role: 'student' }, 36 | { name: 'Mary', age: 3, role: 'student' }, 37 | ], 38 | }).as('users') 39 | cy.visit('cypress/log-examples.html') 40 | cy.wait('@users') 41 | .its('response.body') 42 | .print('list with {0.length} users') 43 | .print('first user {0.0.name}') 44 | .should('be.an', 'array') 45 | }) 46 | 47 | it('prints using custom format callback', () => { 48 | cy.intercept('/users', { 49 | body: [ 50 | { name: 'Joe', age: 1, role: 'student' }, 51 | { name: 'Ann', age: 2, role: 'student' }, 52 | { name: 'Mary', age: 3, role: 'student' }, 53 | ], 54 | }).as('users') 55 | cy.visit('cypress/log-examples.html') 56 | cy.wait('@users') 57 | .its('response.body') 58 | .print((list) => `first names only ${list.map((l) => l.name).join(',')}`) 59 | .should('be.an', 'array') 60 | }) 61 | 62 | it('retries', () => { 63 | const person = { 64 | name: 'Joe', 65 | } 66 | cy.wrap(person) 67 | .print('first name is {0.name}') 68 | .its('name') 69 | .should('equal', 'Ann') 70 | setTimeout(() => { 71 | person.name = 'Ann' 72 | }, 1000) 73 | }) 74 | }) 75 | 76 | describe.skip('cy.log', () => { 77 | it('logs the user age (NOT)', () => { 78 | cy.visit('cypress/log-examples.html') 79 | cy.contains('#age', /\d+/) 80 | .invoke('text') 81 | .then(console.log) 82 | .then(Number) 83 | // what's my age again? 84 | .should('be.within', 1, 99) 85 | }) 86 | 87 | it('retries (NOT)', () => { 88 | const person = {} 89 | cy.wrap(person) 90 | // @ts-ignore 91 | .log() 92 | .its('name') 93 | .should('equal', 'Ann') 94 | setTimeout(() => { 95 | person.name = 'Ann' 96 | }, 1000) 97 | }) 98 | 99 | it('logs the object (NOT)', () => { 100 | cy.intercept('/users', { 101 | body: [ 102 | { name: 'Joe', age: 1, role: 'student' }, 103 | { name: 'Ann', age: 2, role: 'student' }, 104 | { name: 'Mary', age: 3, role: 'student' }, 105 | ], 106 | }).as('users') 107 | cy.visit('cypress/log-examples.html') 108 | cy.wait('@users').its('response.body').should('be.an', 'array') 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /cypress/e2e/make.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('uses a constructor in a query step', () => { 7 | cy.wrap('Jan 1, 2019') 8 | .make(Date) 9 | .invoke('getFullYear') 10 | .should('equal', 2019) 11 | }) 12 | 13 | it('retries', () => { 14 | const list = [] 15 | cy.wrap(list) 16 | .its(0) 17 | .make(Date) 18 | .invoke('getFullYear') 19 | .should('equal', 2019) 20 | setTimeout(() => { 21 | list[0] = 'Jan 1, 2019' 22 | }, 1000) 23 | }) 24 | -------------------------------------------------------------------------------- /cypress/e2e/map-chain.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | import '../../commands' 4 | 5 | const doubleIt = (n) => n + n 6 | 7 | async function asyncDouble(n) { 8 | await Cypress.Promise.delay(500) 9 | return n + n 10 | } 11 | 12 | it('maps each item using sync function', () => { 13 | cy.wrap([1, 2, 3]) 14 | .mapChain(doubleIt) 15 | .should('deep.equal', [2, 4, 6]) 16 | }) 17 | 18 | it('maps each item using async function', () => { 19 | cy.wrap([1, 2, 3]) 20 | .mapChain(asyncDouble) 21 | .should('deep.equal', [2, 4, 6]) 22 | }) 23 | 24 | it('maps each item to the cy yielded value of the returned chain', () => { 25 | cy.wrap([1, 2, 3]) 26 | .mapChain((x) => cy.wrap(x).then(doubleIt)) 27 | .should('deep.equal', [2, 4, 6]) 28 | }) 29 | 30 | it('maps each item using a Promise', () => { 31 | cy.wrap([1, 2, 3]) 32 | .mapChain((x) => { 33 | return new Promise((resolve) => { 34 | setTimeout(() => { 35 | resolve(doubleIt(x)) 36 | }, 500) 37 | }) 38 | }) 39 | .should('deep.equal', [2, 4, 6]) 40 | }) 41 | 42 | // https://github.com/bahmutov/cypress-map/issues/27 43 | it('maps each item to the cy yielded value without returned chain', () => { 44 | cy.wrap([1, 2, 3]) 45 | .mapChain((x) => { 46 | // do not return the command chain 47 | // but it still should grab the resolved value 48 | cy.wrap(x).then(doubleIt) 49 | }) 50 | .should('deep.equal', [2, 4, 6]) 51 | }) 52 | 53 | it('works for an empty array', () => { 54 | cy.wrap([]) 55 | .mapChain(() => { 56 | throw new Error('Should not call mapChain callback') 57 | }) 58 | .should('deep.equal', []) 59 | }) 60 | 61 | it('works for a filtered empty array', () => { 62 | cy.wrap([1, 2, 3]) 63 | .invoke('filter', () => false) 64 | .mapChain(() => { 65 | throw new Error('Should not call mapChain callback') 66 | }) 67 | .should('deep.equal', []) 68 | }) 69 | -------------------------------------------------------------------------------- /cypress/e2e/map-invoke.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('confirms the prices', () => { 7 | cy.visit('cypress/prices.html') 8 | cy.get('#items li') 9 | .find('.price') 10 | .map('innerText') 11 | .mapInvoke('replace', '$', '') 12 | .map(parseFloat) 13 | .should('deep.equal', [1.99, 2.99, 3.99]) 14 | }) 15 | 16 | it('respects the timeout option', () => { 17 | const strings = [] 18 | setTimeout(() => { 19 | strings.push('a') 20 | }, 1000) 21 | setTimeout(() => { 22 | strings.push('b') 23 | }, 2000) 24 | setTimeout(() => { 25 | strings.push('c') 26 | }, 3000) 27 | setTimeout(() => { 28 | strings.push('d') 29 | }, 4000) 30 | setTimeout(() => { 31 | strings.push('e') 32 | }, 5000) 33 | setTimeout(() => { 34 | strings.push('f') 35 | }, 6000) 36 | setTimeout(() => { 37 | strings.push('g') 38 | }, 7000) 39 | cy.wrap(strings) 40 | .mapInvoke('toUpperCase', { timeout: 10_000 }) 41 | .should('deep.equal', ['A', 'B', 'C', 'D', 'E', 'F', 'G']) 42 | }) 43 | -------------------------------------------------------------------------------- /cypress/e2e/map.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../src/commands' 5 | 6 | const getTexts = ($el) => { 7 | return Cypress._.map($el, 'innerText') 8 | } 9 | 10 | it.skip('confirms the list without retries', () => { 11 | cy.visit('cypress/index.html') 12 | cy.get('.matching') 13 | .then(getTexts) 14 | .should('deep.equal', ['first', 'third', 'fourth']) 15 | }) 16 | 17 | it('confirms the list', () => { 18 | cy.visit('cypress/index.html') 19 | cy.get('.matching') 20 | .map('innerText') 21 | .should('deep.equal', ['first', 'third', 'fourth']) 22 | }) 23 | 24 | it('confirms the last element text', () => { 25 | cy.visit('cypress/index.html') 26 | // cy.last is a query command 27 | cy.get('.matching') 28 | .last() 29 | .map('innerText') 30 | .should('deep.equal', ['fourth']) 31 | }) 32 | 33 | it('confirms the last two elements text', () => { 34 | cy.visit('cypress/index.html') 35 | cy.get('.matching') 36 | .map('innerText') 37 | // cy.invoke is a query command 38 | .invoke('slice', -2) 39 | .should('deep.equal', ['third', 'fourth']) 40 | }) 41 | 42 | it('makes the callback unary', () => { 43 | cy.wrap(['1', '2', '3', '4']) 44 | .map(parseInt) 45 | .should('deep.equal', [1, 2, 3, 4]) 46 | }) 47 | 48 | it('verifies that LI elements include 3 strings', () => { 49 | // let's say we do not care the order of strings, just that 50 | // the list includes strings "first", "fifth", "third" 51 | // in any order 52 | cy.visit('cypress/index.html') 53 | cy.get('li') 54 | .map('innerText') 55 | .should('include.members', ['first', 'fifth', 'third']) 56 | }) 57 | 58 | it('maps properties of an object', () => { 59 | cy.wrap({ 60 | age: '42', 61 | lucky: true, 62 | }) 63 | // cast the property "age" to a number 64 | // by running it through the "Number" function 65 | .map({ 66 | age: Number, 67 | lucky: Cypress._.identity, 68 | }) 69 | .should('deep.equal', { 70 | age: 42, 71 | lucky: true, 72 | }) 73 | }) 74 | 75 | describe('Picking properties', () => { 76 | // https://github.com/bahmutov/cypress-map/issues/110 77 | it('picks the listed properties of the subject', () => { 78 | const person = { 79 | name: 'Joe', 80 | age: 21, 81 | occupation: 'student', 82 | } 83 | cy.wrap(person, { timeout: 0 }) 84 | .map(['name', 'age']) 85 | .should('deep.equal', { 86 | name: 'Joe', 87 | age: 21, 88 | }) 89 | }) 90 | 91 | it('retries to find the picked properties', () => { 92 | const person = {} 93 | setTimeout(() => { 94 | person.name = 'Joe' 95 | person.occupation = 'student' 96 | person.age = 21 97 | }, 1000) 98 | cy.wrap(person, { timeout: 1100 }) 99 | .map(['name', 'age']) 100 | .should('deep.equal', { 101 | name: 'Joe', 102 | age: 21, 103 | }) 104 | }) 105 | 106 | it('picks multiple nested properties', () => { 107 | const person = {} 108 | setTimeout(() => { 109 | person.name = { first: 'Joe', last: 'Smith' } 110 | person.occupation = 'student' 111 | person.age = 21 112 | }, 1000) 113 | cy.wrap(person, { timeout: 1100 }) 114 | // should pick the property "age" 115 | // and should pick the nested property "name.first" 116 | // and store it under the name "first" 117 | .map(['name.first', 'age']) 118 | .should('deep.equal', { 119 | first: 'Joe', 120 | age: 21, 121 | }) 122 | }) 123 | 124 | it('maps nested paths', () => { 125 | const people = [ 126 | { 127 | name: { 128 | first: 'Joe', 129 | last: 'Smith', 130 | }, 131 | }, 132 | { 133 | name: { 134 | first: 'Anna', 135 | last: 'Kova', 136 | }, 137 | }, 138 | ] 139 | cy.wrap(people) 140 | .map('name.first') 141 | .should('deep.equal', ['Joe', 'Anna']) 142 | }) 143 | 144 | it('maps paths over an array subject', () => { 145 | const people = [ 146 | { 147 | name: { 148 | first: 'Joe', 149 | last: 'Smith', 150 | }, 151 | }, 152 | { 153 | name: { 154 | first: 'Anna', 155 | last: 'Kova', 156 | }, 157 | }, 158 | ] 159 | cy.wrap(people) 160 | .map(['name.first', 'name.last']) 161 | .should('deep.equal', [ 162 | { first: 'Joe', last: 'Smith' }, 163 | { first: 'Anna', last: 'Kova' }, 164 | ]) 165 | }) 166 | 167 | it('maps combo paths over an array subject', () => { 168 | const people = [ 169 | { 170 | name: { 171 | first: 'Joe', 172 | last: 'Smith', 173 | }, 174 | age: 21, 175 | }, 176 | { 177 | name: { 178 | first: 'Anna', 179 | last: 'Kova', 180 | }, 181 | age: 22, 182 | }, 183 | ] 184 | cy.wrap(people) 185 | // some paths are nested, some are not 186 | .map(['name.first', 'age']) 187 | .should('deep.equal', [ 188 | { first: 'Joe', age: 21 }, 189 | { first: 'Anna', age: 22 }, 190 | ]) 191 | }) 192 | }) 193 | 194 | it('respects the timeout option', () => { 195 | const people = [ 196 | { 197 | name: { 198 | first: 'Joe', 199 | last: 'Smith', 200 | }, 201 | }, 202 | ] 203 | setTimeout(() => { 204 | people.push({ 205 | name: { 206 | first: 'Anna', 207 | last: 'Kova', 208 | }, 209 | }) 210 | }, 6000) 211 | cy.wrap(people) 212 | .map('name.first', { timeout: 7_000 }) 213 | .should('deep.equal', ['Joe', 'Anna']) 214 | }) 215 | 216 | it('respects the timeout option from the parent command', () => { 217 | const people = [ 218 | { 219 | name: { 220 | first: 'Joe', 221 | last: 'Smith', 222 | }, 223 | }, 224 | ] 225 | setTimeout(() => { 226 | people.push({ 227 | name: { 228 | first: 'Anna', 229 | last: 'Kova', 230 | }, 231 | }) 232 | }, 6000) 233 | cy.wrap(people, { timeout: 10_000 }) 234 | .map('name') // should use the timeout from the parent command 235 | .map('first') // should use the timeout from the parent command 236 | .should('deep.equal', ['Joe', 'Anna']) 237 | }) 238 | 239 | // enable only to see the thrown errors 240 | // https://github.com/bahmutov/cypress-map/issues/74 241 | describe.skip( 242 | 'invalid subjects', 243 | { defaultCommandTimeout: 0 }, 244 | () => { 245 | it('throws on a string', () => { 246 | cy.wrap('hello').map(parseInt).print() 247 | }) 248 | 249 | it('throws on a number', () => { 250 | cy.wrap(42).map(parseInt).print() 251 | }) 252 | 253 | it('throws on a boolean', () => { 254 | cy.wrap(true).map(parseInt).print() 255 | }) 256 | }, 257 | ) 258 | -------------------------------------------------------------------------------- /cypress/e2e/multiple-import.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('works after multiple imports', () => { 7 | cy.wrap(['one', 'two']).map('length').should('deep.equal', [3, 3]) 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/e2e/partial.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('applies the partially applied callback', () => { 7 | cy.wrap(100).partial(Cypress._.add, 5).should('equal', 105) 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/e2e/primo.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('converts the first item text to uppercase', () => { 7 | cy.visit('cypress/index.html') 8 | cy.get('.matching') 9 | .map('innerText') 10 | .primo() 11 | .invoke('toUpperCase') 12 | .should('equal', 'FIRST') 13 | }) 14 | 15 | it('yields the first DOM element', () => { 16 | cy.visit('cypress/index.html') 17 | cy.get('.matching') 18 | .primo() 19 | .invoke('getAttributeNames') 20 | .should('deep.equal', ['class']) 21 | }) 22 | -------------------------------------------------------------------------------- /cypress/e2e/print.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | describe('% notation', () => { 7 | it('prints a number by default', () => { 8 | cy.wrap(42) 9 | .print() // "42" 10 | // and yields the value 11 | .should('equal', 42) 12 | }) 13 | 14 | it('prints a string by default', () => { 15 | cy.wrap('hello') 16 | .print() // "hello" 17 | .should('be.a', 'string') 18 | }) 19 | 20 | it('prints an object by default', () => { 21 | cy.wrap({ name: 'Joe' }).print() // {"name":"Joe"} 22 | }) 23 | 24 | it('formats a number using %d', () => { 25 | cy.wrap(42) 26 | .print('the answer is %d') // "the answer is 42" 27 | .should('equal', 42) 28 | }) 29 | 30 | it('prints an object using %o notation', () => { 31 | cy.wrap({ name: 'Joe' }).print('person %o') // 'person {"name":"Joe"}' 32 | }) 33 | 34 | it('prints an array of numbers', () => { 35 | cy.wrap([1, 2, 3]).print() 36 | }) 37 | 38 | it('prints an array of strings', () => { 39 | cy.wrap(['one', 'two', 'three']).print() 40 | }) 41 | }) 42 | 43 | describe('{} notation', () => { 44 | it('prints an object using {0} notation', () => { 45 | cy.wrap({ name: 'Joe' }) 46 | .print('person {0}') // 'person {"name":"Joe"}' 47 | .should('deep.equal', { name: 'Joe' }) 48 | }) 49 | 50 | it('prints a property using {0.name} notation', () => { 51 | cy.wrap({ name: 'Joe' }) 52 | .print('person name {0.name}') // "person name Joe" 53 | .should('deep.equal', { name: 'Joe' }) 54 | }) 55 | 56 | it('prints a nested property using {0.foo.bar} notation', () => { 57 | cy.wrap({ name: { first: 'Joe' } }) 58 | .print('first name is {0.name.first}') // "first name is Joe" 59 | .its('name.first') 60 | .should('equal', 'Joe') 61 | }) 62 | 63 | it('prints the length of an array', () => { 64 | const arr = [1, 2, 3] 65 | cy.wrap(arr) 66 | .print('array length {0.length}') 67 | .its('length') 68 | .should('equal', 4) 69 | setTimeout(() => { 70 | arr.push(4) 71 | }, 1000) 72 | }) 73 | }) 74 | 75 | describe('format callback', () => { 76 | it('passes the subject and prints the result', () => { 77 | const person = { name: 'Joe' } 78 | cy.wrap(person) 79 | .print((p) => `name is ${p.name}`) 80 | .its('name') 81 | .should('equal', 'Ann') 82 | setTimeout(() => { 83 | person.name = 'Ann' 84 | }, 1000) 85 | }) 86 | 87 | it('can return an object to be stringified', () => { 88 | cy.wrap([1, 2, 3]).print((list) => list[1]) // "2" 89 | cy.wrap({ name: 'Me' }).print((x) => x) // {"name":"Me"} 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /cypress/e2e/prop.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('yields the object property using cy.its', () => { 7 | const o = {} 8 | cy.wrap(o).its('name').should('equal', 'Joe') 9 | 10 | setTimeout(() => { 11 | o.name = 'Joe' 12 | }, 1000) 13 | }) 14 | 15 | it('yields the object property using cy.prop', () => { 16 | const o = {} 17 | cy.wrap(o).prop('name').should('equal', 'Joe') 18 | 19 | setTimeout(() => { 20 | o.name = 'Joe' 21 | }, 1000) 22 | }) 23 | 24 | it('yields the DOM prop', () => { 25 | cy.visit('cypress/index.html') 26 | // this does not work with cy.its 27 | cy.get('#items li.matching').last().prop('ariaLabel').should('equal', 'four') 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/e2e/reduce.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('confirms the highest price', () => { 7 | cy.visit('cypress/prices.html') 8 | cy.get('#items li') 9 | .find('.price') 10 | .map('innerText') 11 | .mapInvoke('replace', '$', '') 12 | .map(parseFloat) 13 | .reduce((max, price) => (price > max ? price : max)) 14 | .should('equal', 3.99) 15 | }) 16 | 17 | it('work with wrapped array', () => { 18 | cy.wrap([1, 10, 2, 5, 3]) 19 | .reduce((max, n) => (n > max ? n : max)) 20 | .should('equal', 10) 21 | }) 22 | 23 | it('work with wrapped array with async added items', () => { 24 | const list = [1, 2, 5, 3] 25 | cy.wrap(list) 26 | .reduce((max, n) => (n > max ? n : max)) 27 | .should('equal', 10) 28 | setTimeout(() => { 29 | list.push(10) 30 | }, 1000) 31 | }) 32 | 33 | it('uses the initial value', () => { 34 | const sum = (sum, n) => sum + n 35 | cy.wrap([1, 2, 3]).reduce(sum, 10).should('equal', 16) 36 | }) 37 | -------------------------------------------------------------------------------- /cypress/e2e/sample.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('picks a random item from the list', () => { 7 | const list = ['foo', 'bar', 'baz'] 8 | cy.wrap(list).sample().should('be.oneOf', list) 9 | }) 10 | 11 | it('picks a random element', () => { 12 | cy.visit('cypress/index.html') 13 | // the first item is immediately in the list 14 | cy.get('#items li').sample().should('have.text', 'first') 15 | cy.log('**retries until the item appears and is picked**') 16 | cy.get('#items li').sample().should('have.text', 'fifth') 17 | }) 18 | 19 | it('picks N random items', () => { 20 | const list = ['foo', 'bar', 'baz'] 21 | cy.wrap(list).sample(2).should('have.length', 2) 22 | }) 23 | 24 | it('picks N random elements', () => { 25 | cy.visit('cypress/index.html') 26 | // the first item is immediately in the list 27 | cy.get('#items li').sample().should('have.text', 'first') 28 | cy.log('**retries until all items appear**') 29 | cy.get('#items li') 30 | .sample(5) 31 | .should('have.length', 5) 32 | .and('satisfy', Cypress.dom.isJquery) 33 | .map('innerText') 34 | .invoke('sort') 35 | .should('deep.equal', [ 36 | 'fifth', 37 | 'first', 38 | 'fourth', 39 | 'second', 40 | 'third', 41 | ]) 42 | cy.log('**elements are random**') 43 | cy.get('#items li').sample(2).map('innerText').print() 44 | }) 45 | 46 | it('stores value in a static alias', () => { 47 | cy.wrap('foo') 48 | .as('word', { type: 'static' }) 49 | .then(console.log) 50 | .should('equal', 'foo') 51 | cy.get('@word').then(console.log).should('equal', 'foo') 52 | }) 53 | 54 | // TODO: make sure the value of sample is evaluated just once 55 | // and saved as a static alias 56 | it('stores sample in the static alias', () => { 57 | let value 58 | cy.wrap(['foo', 'bar', 'baz', 'quux', 'quuz']) 59 | .sample() 60 | .then(console.log) 61 | .as('word', { type: 'static' }) 62 | .should('be.a', 'string') 63 | .then((w) => (value = w)) 64 | 65 | // TODO: value should be the same as the first sample 66 | cy.get('@word').should((word) => { 67 | expect(word, 'static aliased value').to.equal(value) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /cypress/e2e/second.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('picks the second item in the list', () => { 7 | const list = ['foo', 'bar', 'baz'] 8 | cy.wrap(list).second().should('equal', 'bar') 9 | }) 10 | 11 | it('yields the second element', () => { 12 | cy.visit('cypress/index.html') 13 | cy.get('#items li').second().should('have.text', 'second') 14 | }) 15 | 16 | it('yields the second element with retries', () => { 17 | cy.visit('cypress/index.html') 18 | // the element appears only after a delay 19 | cy.get('#items li:not(.matching)') 20 | .second() 21 | .should('have.text', 'fifth') 22 | }) 23 | -------------------------------------------------------------------------------- /cypress/e2e/stable-css/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | 26 | 27 |

Stable CSS

28 |
Some text
29 |
30 | 31 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /cypress/e2e/stable-css/stable-css.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | import '../../../src/commands' 4 | 5 | beforeEach(() => { 6 | cy.visit('cypress/e2e/stable-css/index.html') 7 | }) 8 | 9 | it('waits for the color using have.css', () => { 10 | cy.contains('button', 'Click to show').click() 11 | cy.get('#message').should( 12 | 'have.css', 13 | 'background-color', 14 | 'rgb(255, 0, 0)', 15 | ) 16 | }) 17 | 18 | it('waits for the color to be stable using cy.invoke retries', () => { 19 | cy.contains('button', 'Click to show').click() 20 | // retries until the CSS animation finishes 21 | // and the background color is red 22 | cy.get('#message') 23 | .invoke('css', 'background-color') 24 | .should('equal', 'rgb(255, 0, 0)') 25 | }) 26 | 27 | it('waits for the color to be stable using cy.stable', () => { 28 | cy.contains('button', 'Click to show').click() 29 | // retries until the CSS animation finishes 30 | // and the background color is red 31 | cy.get('#message') 32 | .stable('css', 'background-color', 100) 33 | // yields the element 34 | .should('have.css', 'background-color', 'rgb(255, 0, 0)') 35 | }) 36 | 37 | it('waits for the text color to be stable using cy.stable', () => { 38 | cy.contains('button', 'Click to show').click() 39 | cy.get('#message') 40 | .stable('css', 'color', 100) 41 | .should('have.css', 'color', 'rgb(255, 255, 255)') 42 | }) 43 | -------------------------------------------------------------------------------- /cypress/e2e/stable/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
--
4 |
5 | 6 |
7 |
--
8 | 9 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /cypress/e2e/stable/stable.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | import '../../../src/commands' 4 | 5 | beforeEach(() => { 6 | cy.visit('cypress/e2e/stable/index.html') 7 | }) 8 | 9 | it('waits for the element text to be stable', () => { 10 | cy.get('#message').stable('text').should('have.text', 'Hello') 11 | }) 12 | 13 | it('controls the logging', () => { 14 | cy.get('#message').stable('text', 1000, { log: false }) 15 | }) 16 | 17 | it('controls the timeout', () => { 18 | cy.get('#message').stable('text', 5000, { timeout: 10_000 }) 19 | }) 20 | 21 | it('waits for the input value to be stable', () => { 22 | cy.get('#name').stable('value') 23 | cy.get('#name', { timeout: 0 }).should('have.value', 'World') 24 | }) 25 | 26 | it('waits for the element reference to be stable', () => { 27 | cy.contains('button', 'Replace result').click() 28 | cy.get('#result').stable('element', 1000, { 29 | timeout: 3_000, 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /cypress/e2e/table-rows.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // https://github.com/bahmutov/cy-spok 5 | import spok from 'cy-spok' 6 | 7 | // import cypress-map plugin 8 | import '../../commands' 9 | 10 | describe( 11 | 'table rows', 12 | { viewportWidth: 300, viewportHeight: 200, defaultCommandTimeout: 100 }, 13 | () => { 14 | beforeEach(() => { 15 | cy.visit('cypress/table.html') 16 | // confirm the table headings 17 | const headings = ['Name', 'Age', 'Date (YYYY-MM-DD)'] 18 | cy.get('table thead td').map('innerText').should('deep.equal', headings) 19 | }) 20 | 21 | it('confirms the first row (text)', () => { 22 | cy.get('table tbody tr') 23 | .first() 24 | .find('td') 25 | // warning: no retries 26 | .then((td$) => { 27 | return { 28 | name: td$[0].innerText, 29 | age: td$[1].innerText, 30 | date: td$[2].innerText, 31 | } 32 | }) 33 | .print() 34 | .should('deep.equal', { 35 | name: 'Dave', 36 | age: '20', 37 | date: '2023-12-23', 38 | }) 39 | }) 40 | 41 | it('confirms the first row (spread)', () => { 42 | cy.get('table tbody tr') 43 | .first() 44 | .find('td') 45 | // warning: spread is NOT a query, so it won't retry 46 | .spread((name, age, date) => { 47 | return { 48 | name: name.innerText, 49 | age: age.innerText, 50 | date: date.innerText, 51 | } 52 | }) 53 | .print() 54 | .should('deep.equal', { 55 | name: 'Dave', 56 | age: '20', 57 | date: '2023-12-23', 58 | }) 59 | }) 60 | 61 | const props = ['name', 'age', 'date'] 62 | it('confirms the first row', () => { 63 | cy.get('table tbody tr') 64 | .first() 65 | .find('td') 66 | .map('innerText') 67 | .print() 68 | .apply((values) => Cypress._.zipObject(props, values)) 69 | .print() 70 | .should('deep.equal', { 71 | name: 'Dave', 72 | age: '20', 73 | date: '2023-12-23', 74 | }) 75 | }) 76 | 77 | it('confirms the first row (convert values)', () => { 78 | cy.get('table tbody tr') 79 | .first() 80 | .find('td') 81 | .map('innerText') 82 | .print() 83 | .apply((values) => Cypress._.zipObject(props, values)) 84 | .apply((person) => { 85 | person.age = Number(person.age) 86 | return person 87 | }) 88 | .print() 89 | .should('deep.include', { 90 | name: 'Dave', 91 | age: 20, 92 | }) 93 | }) 94 | 95 | it('confirms the first row (convert values using update)', () => { 96 | cy.get('table tbody tr') 97 | .first() 98 | .find('td') 99 | .map('innerText') 100 | .print() 101 | .apply((values) => Cypress._.zipObject(props, values)) 102 | .update('age', Number) 103 | .print() 104 | .should('deep.include', { 105 | name: 'Dave', 106 | age: 20, 107 | }) 108 | }) 109 | 110 | it('confirms the first row with update and cy-spok', () => { 111 | cy.get('table tbody tr') 112 | .first() 113 | .find('td') 114 | .map('innerText') 115 | .print() 116 | .apply((values) => Cypress._.zipObject(props, values)) 117 | .update('age', Number) 118 | .print() 119 | .should( 120 | spok({ 121 | name: 'Dave', 122 | age: spok.range(1, 99), 123 | date: spok.test(/^\d\d\d\d-\d\d-\d\d$/), 124 | }), 125 | ) 126 | }) 127 | 128 | it('confirms the first row with partial, update, and cy-spok', () => { 129 | cy.get('table tbody tr') 130 | .first() 131 | .find('td') 132 | .map('innerText') 133 | .print() 134 | .partial(Cypress._.zipObject, props) 135 | .update('age', Number) 136 | .print() 137 | .should( 138 | spok({ 139 | name: 'Dave', 140 | age: spok.range(1, 99), 141 | date: spok.test(/^\d\d\d\d-\d\d-\d\d$/), 142 | }), 143 | ) 144 | }) 145 | 146 | it('confirms the row with Anna', () => { 147 | cy.contains('table tbody tr', 'Anna') 148 | .find('td') 149 | .map('innerText') 150 | .print() 151 | .partial(Cypress._.zipObject, props) 152 | .update('age', Number) 153 | .print() 154 | .should( 155 | spok({ 156 | name: 'Anna', 157 | age: spok.range(10, 30), 158 | date: spok.test(/^\d\d\d\d-\d\d-\d\d$/), 159 | }), 160 | ) 161 | }) 162 | }, 163 | ) 164 | -------------------------------------------------------------------------------- /cypress/e2e/table.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | // import cypress-map plugin 5 | import '../../commands' 6 | 7 | describe('table', { viewportWidth: 300, viewportHeight: 200 }, () => { 8 | beforeEach(() => { 9 | cy.visit('cypress/table.html') 10 | }) 11 | 12 | it('checks the column of cells', () => { 13 | cy.contains('table#people thead td:nth-child(2)', 'Age') 14 | cy.get('table#people tbody td:nth-child(2)') 15 | .should('have.length', 4) 16 | .map('innerText') 17 | .map(Number) 18 | .should('deep.equal', [20, 30, 28, 22]) 19 | }) 20 | 21 | it('gets the entire table', () => { 22 | cy.get('table') 23 | .table() 24 | .should('deep.equal', [ 25 | ['Name', 'Age', 'Date (YYYY-MM-DD)'], 26 | ['Dave', '20', '2023-12-23'], 27 | ['Cary', '30', '2024-01-24'], 28 | ['Joe', '28', '2022-02-25'], 29 | ['Anna', '22', '2027-03-26'], 30 | ]) 31 | }) 32 | 33 | it('gets the table body', () => { 34 | cy.get('table tbody') 35 | .table() 36 | .should('deep.equal', [ 37 | ['Dave', '20', '2023-12-23'], 38 | ['Cary', '30', '2024-01-24'], 39 | ['Joe', '28', '2022-02-25'], 40 | ['Anna', '22', '2027-03-26'], 41 | ]) 42 | }) 43 | 44 | it('gets the headings', () => { 45 | cy.get('table') 46 | .table(0, 0, 3, 1) 47 | .its(0) 48 | .print() 49 | .should('deep.equal', ['Name', 'Age', 'Date (YYYY-MM-DD)']) 50 | }) 51 | 52 | it('gets the headings row', () => { 53 | cy.get('table') 54 | .table(0, 0) 55 | .its(0) 56 | .print() 57 | .should('deep.equal', ['Name', 'Age', 'Date (YYYY-MM-DD)']) 58 | }) 59 | 60 | it('gets the headings row from thead', () => { 61 | cy.get('table thead') 62 | .table(0, 0) 63 | .should('have.length', 1) 64 | .its(0) 65 | .print() 66 | .should('deep.equal', ['Name', 'Age', 'Date (YYYY-MM-DD)']) 67 | }) 68 | 69 | it('gets the first column', () => { 70 | cy.get('table') 71 | .table(0, 0, 1, 5) 72 | .print() 73 | .should('deep.equal', [['Name'], ['Dave'], ['Cary'], ['Joe'], ['Anna']]) 74 | }) 75 | 76 | it('gets the entire first column', () => { 77 | cy.get('table') 78 | .table(0, 0, 1) 79 | .print() 80 | .should('deep.equal', [['Name'], ['Dave'], ['Cary'], ['Joe'], ['Anna']]) 81 | }) 82 | 83 | it('joins the first column into one array', () => { 84 | cy.get('table') 85 | .table(0, 1, 1) // skip the heading "Name" cell 86 | // combine 1x1 arrays into one array 87 | .invoke('flatMap', Cypress._.identity) 88 | .print() 89 | .should('deep.equal', ['Dave', 'Cary', 'Joe', 'Anna']) 90 | }) 91 | 92 | it('gets a region of the table', () => { 93 | cy.get('table') 94 | .table(0, 2, 2, 2) 95 | .print() 96 | .should('deep.equal', [ 97 | ['Cary', '30'], 98 | ['Joe', '28'], 99 | ]) 100 | }) 101 | 102 | it('gets a region of the table using slice', () => { 103 | cy.get('table') 104 | .table() 105 | .invoke('slice', 2, 4) 106 | .mapInvoke('slice', 0, 2) 107 | .print() 108 | .should('deep.equal', [ 109 | ['Cary', '30'], 110 | ['Joe', '28'], 111 | ]) 112 | }) 113 | 114 | it('checks the last row', () => { 115 | cy.get('table') 116 | .table() 117 | .invoke('slice', -1) 118 | .its(0) 119 | .print() 120 | .should('deep.equal', ['Anna', '22', '2027-03-26']) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /cypress/e2e/tap.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('confirms the prices', () => { 7 | cy.visit('cypress/prices.html') 8 | cy.get('#items li') 9 | .find('.price') 10 | .map('innerText') 11 | .tap(console.log, 'text') 12 | .mapInvoke('replace', '$', '') 13 | .tap(console.log, 'without $') 14 | .map(parseFloat) 15 | .tap(console.info, 'numbers') 16 | .should('deep.equal', [1.99, 2.99, 3.99]) 17 | }) 18 | 19 | it('uses console.log by default', () => { 20 | cy.visit('cypress/prices.html') 21 | cy.get('#items li') 22 | .find('.price') 23 | .map('innerText') 24 | .tap() 25 | .mapInvoke('replace', '$', '') 26 | .tap() 27 | .map(parseFloat) 28 | .tap(console.info) 29 | .should('deep.equal', [1.99, 2.99, 3.99]) 30 | }) 31 | 32 | it('uses console.log by default with a label', () => { 33 | cy.visit('cypress/prices.html') 34 | cy.get('#items li') 35 | .find('.price') 36 | .map('innerText') 37 | .tap('text') 38 | .mapInvoke('replace', '$', '') 39 | .map(Number) 40 | .tap('prices') 41 | .should('deep.equal', [1.99, 2.99, 3.99]) 42 | }) 43 | -------------------------------------------------------------------------------- /cypress/e2e/text.cy.js: -------------------------------------------------------------------------------- 1 | // confirm the first LI element has text 2 | // that is either 'first' or 'primo' 3 | 4 | // 📺 video "Check If Element Text Is One Of Possible Strings" 5 | // https://youtu.be/KSlJYjIn_AM 6 | it('checks the text is one of the variants: single should callback', () => { 7 | cy.visit('cypress/index.html') 8 | cy.get('#items li').should(($li) => { 9 | expect($li.first().text()).to.be.oneOf(['first', 'primo']) 10 | }) 11 | }) 12 | 13 | it('checks the text is one of the variants: regex', () => { 14 | cy.visit('cypress/index.html') 15 | cy.contains('#items li:first', /^(first|primo)$/) 16 | }) 17 | 18 | it('checks the text is one of the variants: v12 queries', () => { 19 | cy.visit('cypress/index.html') 20 | cy.get('#items li') 21 | .first() 22 | .invoke('text') 23 | .should('be.oneOf', ['first', 'primo']) 24 | }) 25 | -------------------------------------------------------------------------------- /cypress/e2e/third.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | it('picks the third item in the list', () => { 7 | const list = ['foo', 'bar', 'baz'] 8 | cy.wrap(list).third().should('equal', 'baz') 9 | }) 10 | 11 | it('yields the third element', () => { 12 | cy.visit('cypress/index.html') 13 | cy.get('#items li').third().should('have.text', 'third') 14 | }) 15 | 16 | it('yields the third element with retries', () => { 17 | cy.visit('cypress/index.html') 18 | // the element appears only after a delay 19 | cy.get('#items li.matching').third().should('have.text', 'fourth') 20 | }) 21 | -------------------------------------------------------------------------------- /cypress/e2e/to-plain-object.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | // https://github.com/bahmutov/cy-spok 6 | import spok from 'cy-spok' 7 | 8 | describe('to plain object', () => { 9 | beforeEach(() => { 10 | cy.visit('cypress/dataset.html') 11 | }) 12 | 13 | it('applies JSON stringify and parse', () => { 14 | cy.get('article') 15 | .should('have.attr', 'data-columns', '3') 16 | .invoke('prop', 'dataset') 17 | // convert from DOMStringMap to a plain object 18 | .apply(JSON.stringify) 19 | .apply(JSON.parse) 20 | .should('deep.equal', { 21 | columns: '3', 22 | indexNumber: '12314', 23 | parent: 'cars', 24 | }) 25 | }) 26 | 27 | it('uses cy-spok with DOMStringMap', () => { 28 | cy.get('article') 29 | .should('have.attr', 'data-columns', '3') 30 | .invoke('prop', 'dataset') 31 | .should( 32 | spok({ 33 | columns: '3', 34 | indexNumber: '12314', 35 | parent: 'cars', 36 | }), 37 | ) 38 | }) 39 | 40 | it('uses cy.toPlainObject custom query', () => { 41 | cy.get('article') 42 | .should('have.prop', 'dataset') 43 | .toPlainObject() 44 | .should('deep.equal', { 45 | columns: '3', 46 | indexNumber: '12314', 47 | parent: 'cars', 48 | }) 49 | }) 50 | }) 51 | 52 | it('uses entries to convert', () => { 53 | const searchParams = 54 | '?callback=%2Fmy-profile-page.html&question=what%20is%20the%20meaning%20of%20life%3F&answer=42' 55 | cy.wrap(new URLSearchParams(searchParams)) 56 | .toPlainObject('entries') 57 | .should('deep.equal', { 58 | callback: '/my-profile-page.html', 59 | question: 'what is the meaning of life?', 60 | answer: '42', 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /cypress/e2e/unique.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../commands' 5 | 6 | describe('unique', () => { 7 | it('checks array elements are unique', () => { 8 | cy.wrap([1, 2, 3]) 9 | .should('be.unique') 10 | .and('have.length', 3) 11 | .its(1) 12 | .should('equal', 2) 13 | cy.wrap([1, 2, 2]) 14 | .should('not.be.unique') 15 | .and('have.length', 3) 16 | .its(1) 17 | .should('equal', 2) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /cypress/e2e/version.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | it('parses Cypress version', () => { 4 | console.log(Cypress.version) 5 | const [major, minor, patch] = Cypress.version.split('.').map(Number) 6 | console.log({ major, minor, patch }) 7 | expect(major, 'Cypress major version').to.be.gte(12) 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/fixtures/people.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Peter", 4 | "date": "2000-01-01", 5 | "age": 23 6 | }, 7 | { 8 | "name": "Pete", 9 | "date": "2000-01-01", 10 | "age": 23 11 | }, 12 | { 13 | "name": "Mary", 14 | "date": "2000-01-01", 15 | "age": 23 16 | }, 17 | { 18 | "name": "Mary-Ann", 19 | "date": "2000-01-01", 20 | "age": 23 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /cypress/index.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 |
    10 |
  • first
  • 11 |
  • second
  • 12 |
  • third
  • 13 |
14 | 26 | 27 | -------------------------------------------------------------------------------- /cypress/list.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
  • Item A 1
  • 4 |
  • Item B 2
  • 5 |
  • Item C 3
  • 6 |
  • Item D 4
  • 7 |
8 | 9 | -------------------------------------------------------------------------------- /cypress/log-examples.html: -------------------------------------------------------------------------------- 1 | 2 |
42
3 | 6 | 7 | -------------------------------------------------------------------------------- /cypress/prices.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 |
    10 |
  • apples $1.99
  • 11 |
12 | 22 | 23 | -------------------------------------------------------------------------------- /cypress/table.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
NameAgeDate (YYYY-MM-DD)
Dave202023-12-23
Cary302024-01-24
Joe282022-02-25
Anna222027-03-26
40 | 41 | -------------------------------------------------------------------------------- /images/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/cypress-map/f8d89e67cc670405db46745f890bd0e9bf3f3e90/images/table.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-map", 3 | "version": "0.0.0-development", 4 | "description": "Extra Cypress query commands for v12+", 5 | "main": "commands/index.js", 6 | "types": "src/commands/index.d.ts", 7 | "files": [ 8 | "commands", 9 | "src/commands/index.d.ts" 10 | ], 11 | "scripts": { 12 | "test": "cypress run", 13 | "badges": "npx -p dependency-version-badge update-badge cypress", 14 | "semantic-release": "semantic-release", 15 | "stop-only": "stop-only --folder cypress/e2e", 16 | "lint": "tsc --noEmit --pretty --allowJs --esModuleInterop src/commands/*.* cypress/*/*.js cypress/*/*/*.js", 17 | "build": "tsc", 18 | "watch": "tsc --watch" 19 | }, 20 | "keywords": [ 21 | "cypress-plugin" 22 | ], 23 | "author": "Gleb Bahmutov ", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "cy-spok": "^1.5.2", 27 | "cypress": "^14.0.0", 28 | "cypress-split": "^1.19.0", 29 | "prettier": "^3.0.0", 30 | "semantic-release": "^23.0.0", 31 | "stop-only": "^3.1.2", 32 | "typescript": "^5.0.0" 33 | }, 34 | "peerDependencies": { 35 | "cypress": ">=12" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/bahmutov/cypress-map.git" 40 | }, 41 | "dependencies": { 42 | "string-format": "^2.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | }, 7 | "minor": { 8 | "automerge": true 9 | }, 10 | "prConcurrentLimit": 3, 11 | "prHourlyLimit": 2, 12 | "schedule": ["every weekend"], 13 | "masterIssue": true, 14 | "labels": ["type: dependencies", "renovate"], 15 | "ignoreDeps": ["cy-spok"] 16 | } 17 | -------------------------------------------------------------------------------- /src/commands/apply.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('apply', (callback, ...args) => { 6 | if (typeof callback !== 'function') { 7 | throw new Error('Expected a function to apply') 8 | } 9 | 10 | const log = Cypress.log({ name: 'apply', message: callback.name }) 11 | 12 | return (subject) => { 13 | log.set({ 14 | $el: subject, 15 | }) 16 | return callback(...args, subject) 17 | } 18 | }) 19 | 20 | registerQuery('applyRight', (callback, ...args) => { 21 | if (typeof callback !== 'function') { 22 | throw new Error('Expected a function to apply') 23 | } 24 | 25 | const log = Cypress.log({ 26 | name: 'applyRight', 27 | message: callback.name, 28 | }) 29 | 30 | return (subject) => { 31 | log.set({ 32 | $el: subject, 33 | }) 34 | return callback(subject, ...args) 35 | } 36 | }) 37 | 38 | registerQuery('applyToFirst', (callback, ...args) => { 39 | if (typeof callback !== 'function') { 40 | throw new Error('Expected a function to apply') 41 | } 42 | 43 | const log = Cypress.log({ 44 | name: 'applyToFirst', 45 | message: callback.name, 46 | }) 47 | 48 | return (subject) => { 49 | if (Cypress.dom.isJquery(subject) || Array.isArray(subject)) { 50 | log.set({ 51 | $el: subject, 52 | }) 53 | return callback(...args, subject[0]) 54 | } else { 55 | throw new Error('Expected a jQuery object or an array subject') 56 | } 57 | } 58 | }) 59 | 60 | registerQuery('applyToFirstRight', (callback, ...args) => { 61 | if (typeof callback !== 'function') { 62 | throw new Error('Expected a function to apply') 63 | } 64 | 65 | const log = Cypress.log({ 66 | name: 'applyToFirstRight', 67 | message: callback.name, 68 | }) 69 | 70 | return (subject) => { 71 | if (Cypress.dom.isJquery(subject) || Array.isArray(subject)) { 72 | log.set({ 73 | $el: subject, 74 | }) 75 | return callback(subject[0], ...args) 76 | } else { 77 | throw new Error('Expected a jQuery object or an array subject') 78 | } 79 | } 80 | }) 81 | -------------------------------------------------------------------------------- /src/commands/as-env.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('asEnv', (name) => { 6 | if (typeof name !== 'string') { 7 | throw new Error(`Invalid cy.asEnv name ${index}`) 8 | } 9 | if (!name) { 10 | throw new Error(`Empty cy.asEnv name`) 11 | } 12 | 13 | const log = Cypress.log({ name: 'asEnv', message: name }) 14 | 15 | return (subject) => { 16 | if (Cypress._.isNil(subject)) { 17 | throw new Error('No subject to save cy.asEnv') 18 | } 19 | Cypress.env(name, subject) 20 | return subject 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/commands/assertions.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | chai.use((_chai) => { 4 | // use "function" syntax to make sure when Chai 5 | // calls it, the "this" object points at Chai 6 | 7 | function readAssertion(strings) { 8 | if (Cypress._.isString(strings) || Cypress._.isRegExp(strings)) { 9 | strings = [strings] 10 | } 11 | 12 | if (strings.length === 0) { 13 | throw new Error( 14 | 'Expected at least one string or regular expression', 15 | ) 16 | } 17 | // TODO: handle longer lists of expected strings 18 | const texts = Cypress._.map(this._obj, 'innerText') 19 | const expectedValuesMessage = strings 20 | .map((s) => { 21 | return Cypress._.isString(s) ? s : s.toString() 22 | }) 23 | .join(', ') 24 | 25 | const message = `expected ${texts.join(', ')} to read ${expectedValuesMessage}` 26 | // confirm the number of elements matches the number of expected strings 27 | this.assert(texts.length === strings.length, message) 28 | 29 | // confirm the texts match the expected strings 30 | const passed = Cypress._.every(strings, (s, i) => { 31 | if (Cypress._.isString(s)) { 32 | return texts[i] === s 33 | } 34 | if (Cypress._.isRegExp(s)) { 35 | return s.test(texts[i]) 36 | } 37 | return false 38 | }) 39 | 40 | this.assert(passed, message) 41 | } 42 | _chai.Assertion.addMethod('read', readAssertion) 43 | 44 | function possesAssertion(propertyName, maybeValueOrPredicate) { 45 | if (typeof propertyName !== 'string') { 46 | throw new Error( 47 | `possess assertion: Expected a string, but got ${typeof propertyName}`, 48 | ) 49 | } 50 | 51 | const subjectText = JSON.stringify(this._obj) 52 | const subjectShort = 53 | subjectText.length > 40 54 | ? `${subjectText.slice(0, 40)}...` 55 | : subjectText 56 | 57 | if (arguments.length === 1) { 58 | return this.assert( 59 | propertyName in this._obj, 60 | `expected ${subjectShort} to possess property "${propertyName}"`, 61 | `expected ${subjectShort} to not possess property "${propertyName}"`, 62 | ) 63 | } 64 | if (arguments.length === 2) { 65 | const value = Cypress._.get(this._obj, propertyName) 66 | if (typeof maybeValueOrPredicate === 'function') { 67 | const functionName = maybeValueOrPredicate.name || 'function' 68 | return this.assert( 69 | maybeValueOrPredicate(value), 70 | `expected ${subjectShort} to pass the ${functionName} predicate`, 71 | `expected ${subjectShort} to not pass the ${functionName} predicate`, 72 | ) 73 | } else { 74 | return this.assert( 75 | value === maybeValueOrPredicate, 76 | `expected ${subjectShort} to possess property ${propertyName}=${maybeValueOrPredicate}`, 77 | `expected ${subjectShort} to not possess property ${propertyName}=${maybeValueOrPredicate}`, 78 | ) 79 | } 80 | } 81 | 82 | throw new Error( 83 | `Unexpected arguments to the "possess" assertion ${[...arguments].join(', ')}`, 84 | ) 85 | } 86 | _chai.Assertion.addMethod('possess', possesAssertion) 87 | 88 | function uniqueAssertion() { 89 | const values = this._obj 90 | 91 | if (!Array.isArray(values)) { 92 | throw new Error( 93 | `unique assertion: Expected an array, but got ${typeof values}`, 94 | ) 95 | } 96 | 97 | // report duplicate values? 98 | const uniqueValues = new Set(values) 99 | return this.assert( 100 | uniqueValues.size === values.length, 101 | `expected ${values} to be unique`, 102 | `expected ${values} to not be unique`, 103 | ) 104 | } 105 | _chai.Assertion.addMethod('unique', uniqueAssertion) 106 | }) 107 | -------------------------------------------------------------------------------- /src/commands/at.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('at', (index) => { 6 | if (typeof index !== 'number') { 7 | throw new Error(`Invalid cy.at index ${index}`) 8 | } 9 | const log = Cypress.log({ name: 'at', message: String(index) }) 10 | 11 | return (subject) => { 12 | if (Cypress.dom.isJquery(subject) || Array.isArray(subject)) { 13 | if (index < 0) { 14 | return subject[subject.length + index] 15 | } 16 | return subject[index] 17 | } 18 | throw new Error(`Not sure how to pick the item at ${index}`) 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /src/commands/detaches.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | const { registerQuery } = require('./utils') 5 | 6 | registerQuery('detaches', (selectorOrAlias) => { 7 | if (typeof selectorOrAlias !== 'string') { 8 | throw new Error('Selector/alias must be a string') 9 | } 10 | 11 | const log = Cypress.log({ 12 | name: 'detaches', 13 | message: String(selectorOrAlias), 14 | }) 15 | 16 | if (selectorOrAlias[0] === '@') { 17 | return () => { 18 | const $el = Cypress.env(selectorOrAlias.slice(1)) 19 | if (!$el) { 20 | throw new Error( 21 | `Element with alias "${selectorOrAlias}" not found`, 22 | ) 23 | } 24 | if (!$el.length) { 25 | throw new Error(`Expected one element, found ${$el.length}`) 26 | } 27 | if (Cypress.dom.isAttached($el[0])) { 28 | throw new Error( 29 | `Expected element is still attached to the DOM`, 30 | ) 31 | } 32 | } 33 | } else { 34 | let el = null 35 | return () => { 36 | if (!el) { 37 | // @ts-expect-error 38 | const doc = cy.state('document') 39 | el = doc.querySelector(selectorOrAlias) 40 | } 41 | 42 | if (!Cypress.dom.isDetached(el)) { 43 | throw new Error(`Expected element to be detached`) 44 | } 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/commands/difference.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | const toDifference = (o) => { 6 | if (typeof o === 'function') { 7 | return o.name || 'fn' 8 | } else { 9 | return o 10 | } 11 | } 12 | 13 | function diffTwoObjects(expected, subject) { 14 | const names = Object.keys(expected) 15 | const diff = {} 16 | 17 | names.forEach((name) => { 18 | if (!(name in subject)) { 19 | diff[name] = { missing: true, expected: expected[name] } 20 | } else { 21 | const actual = subject[name] 22 | const expectedValue = expected[name] 23 | // console.log({ name, actual, expectedValue }) 24 | 25 | if (typeof expectedValue === 'function') { 26 | if (expectedValue(actual) === false) { 27 | const predicteName = expectedValue.name 28 | diff[name] = { 29 | message: `value ${actual} did not pass predicate "${predicteName}"`, 30 | } 31 | } 32 | } else if (!Cypress._.isEqual(actual, expectedValue)) { 33 | diff[name] = { actual, expected: expectedValue } 34 | } 35 | } 36 | }) 37 | Object.keys(subject).forEach((name) => { 38 | if (!(name in expected)) { 39 | diff[name] = { extra: true, actual: subject[name] } 40 | } 41 | }) 42 | 43 | return diff 44 | } 45 | 46 | registerQuery('difference', (expected) => { 47 | const logOptions = { 48 | name: 'difference', 49 | type: 'child', 50 | } 51 | if (Array.isArray(expected)) { 52 | logOptions.message = 53 | '[' + expected.map(toDifference).join(', ') + ']' 54 | } else { 55 | logOptions.message = Object.entries(expected) 56 | .map(([k, v]) => `${k}: ${toDifference(v)}`) 57 | .join(', ') 58 | } 59 | const log = Cypress.log(logOptions) 60 | 61 | return (subject) => { 62 | const diff = {} 63 | 64 | if (Array.isArray(subject) && Cypress._.isPlainObject(expected)) { 65 | // check each item in the subject array 66 | // against the expected object or its predicates 67 | subject.forEach((actual, index) => { 68 | const aDiff = diffTwoObjects(expected, actual) 69 | if (!Cypress._.isEmpty(aDiff)) { 70 | diff[index] = aDiff 71 | } 72 | }) 73 | } else { 74 | const aDiff = diffTwoObjects(expected, subject) 75 | Object.assign(diff, aDiff) 76 | } 77 | 78 | log.set('consoleProps', () => { 79 | return { expected, subject, diff } 80 | }) 81 | 82 | return diff 83 | } 84 | }) 85 | -------------------------------------------------------------------------------- /src/commands/elements.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | function getElements(parentSelector, ...childSelectors) { 6 | const log = Cypress.log({ 7 | name: 'elements', 8 | message: `"${parentSelector}" [${childSelectors.join(', ')}]`, 9 | }) 10 | 11 | return () => { 12 | // TODO: add the elements to the log 13 | const doc = cy.state('document') 14 | const texts = [] 15 | Cypress.$(doc) 16 | .find(parentSelector) 17 | .each((index, parent) => { 18 | const childTexts = [] 19 | childSelectors.forEach((childSelector) => { 20 | const child = Cypress.$(parent).find(childSelector) 21 | if (child.length) { 22 | const text = child.text() 23 | childTexts.push(text) 24 | } 25 | }) 26 | texts.push(childTexts) 27 | }) 28 | return texts 29 | } 30 | } 31 | 32 | registerQuery('elements', getElements) 33 | -------------------------------------------------------------------------------- /src/commands/find-one.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('findOne', (predicate) => { 6 | const logOptions = { name: 'findOne' } 7 | if (typeof predicate === 'function') { 8 | logOptions.message = predicate.name 9 | } 10 | const log = Cypress.log(logOptions) 11 | 12 | return (subject) => { 13 | return Cypress._.find(subject, predicate) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /src/commands/get-in-order.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('getInOrder', (...selectors) => { 6 | // if you pass a single array of selectors, use it as the selectors 7 | if ( 8 | Array.isArray(selectors) && 9 | selectors.length === 1 && 10 | Array.isArray(selectors[0]) 11 | ) { 12 | selectors = selectors[0] 13 | } 14 | 15 | if (!Array.isArray(selectors)) { 16 | throw new Error(`Invalid cy.getInOrder selectors ${selectors}`) 17 | } 18 | const log = Cypress.log({ 19 | name: 'getInOrder', 20 | message: selectors.join(','), 21 | }) 22 | 23 | return ($subject) => { 24 | let found = $subject 25 | ? $subject.find(selectors[0]) 26 | : Cypress.$(selectors[0]) 27 | 28 | if (found.length === 0) { 29 | throw new Error(`No elements found for ${selectors[0]}`) 30 | } 31 | 32 | selectors.slice(1).forEach((selector) => { 33 | const next = $subject 34 | ? $subject.find(selector) 35 | : Cypress.$(selector) 36 | if (next.length === 0) { 37 | throw new Error(`No elements found for ${selector}`) 38 | } 39 | // avoid sorting elements by passing the elements as a single array 40 | found = Cypress.$(found.toArray().concat(next.toArray())) 41 | }) 42 | 43 | return found 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /src/commands/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Specifies a function callback for different object properties 3 | */ 4 | interface PropertyCallbacks { 5 | [key: string]: Function 6 | } 7 | 8 | /** 9 | * Type for most Cypress commands to control logging and timeout. 10 | */ 11 | type CyOptions = Partial 12 | 13 | /** 14 | * How to determine if an element is stable. For example, its text content 15 | * should not change for N milliseconds, or its value. The "element" type 16 | * means the element's reference should be stable for N ms. 17 | */ 18 | type StableType = 'text' | 'value' | 'element' 19 | 20 | declare namespace Cypress { 21 | interface Chainable { 22 | /** 23 | * A query command that passes each element from the list or jQuery object 24 | * through the given synchronous function (or extracts the named property) 25 | * @see https://github.com/bahmutov/cypress-map 26 | * @example 27 | * cy.get('#items li').map('innerText') 28 | * @example 29 | * cy.wrap(['10', '20']).map(Number) // [10, 20] 30 | * @example 31 | * cy.wrap({ age: '42' }).map({ age: Number }) // { age: 42 } 32 | * @example 33 | * cy.wrap({ name: 'Joe' age: '42', zip: '90210' }) 34 | * .map(['name', 'age']) // { name: ..., age: ... } 35 | */ 36 | map( 37 | mapper: string | string[] | Function | PropertyCallbacks, 38 | options?: CyOptions, 39 | ): Chainable 40 | 41 | /** 42 | * A query command that takes every item from the current subject 43 | * and calls a method on it by name, passing the given arguments. 44 | * @see https://github.com/bahmutov/cypress-map 45 | * @example 46 | * // remove the $ from each string 47 | * cy.get('#prices li').map('innerText').mapInvoke('replace', '$', '') 48 | */ 49 | mapInvoke(propertyName: string, ...args: any[]): Chainable 50 | 51 | /** 52 | * A regular cy command that takes every item from the current subject 53 | * and calls the given function with the item and its index. 54 | * The function could be synchronous, or async. The function could 55 | * call other Cypress commands and yield the value. All async and 56 | * Cypress commands are queued up and execute one at a time. 57 | * The current subject should be an array. 58 | * Yields the final list of results. 59 | * @see https://github.com/bahmutov/cypress-map 60 | * @example 61 | * // fetch the users from a list of ids 62 | * cy.get(ids).mapChain(id => cy.request('/users/' + id)).then(users => ...) 63 | */ 64 | mapChain(fn: Function): Chainable 65 | 66 | /** 67 | * A query command that can log the data without changing it. Useful 68 | * for debugging longer command chains. 69 | * @param fn Function that does not modify the data, `console.log` by default. 70 | * @param label Extra label to log to the Command Log 71 | * @see https://github.com/bahmutov/cypress-map 72 | * @example 73 | * cy.get('#items li').map('innerText').tap(console.log) 74 | */ 75 | tap(fn?: Function, label?: string): Chainable 76 | 77 | /** 78 | * A query command that can log the data without changing it. Useful 79 | * for debugging longer command chains. 80 | * @param label Extra label to log to the Command Log 81 | * @see https://github.com/bahmutov/cypress-map 82 | * @example 83 | * cy.get('#items li').map('innerText').tap('text') 84 | */ 85 | tap(label: string): Chainable 86 | 87 | /** 88 | * A query command that reduces the list to a single element based on the predicate. 89 | * @see https://github.com/bahmutov/cypress-map 90 | * @param fn Callback function that takes the current accumulator and item 91 | * @param initialValue Optional starting value 92 | * @example 93 | * cy.get('#items li').map('innerText') 94 | */ 95 | reduce(fn: Function, initialValue?: any): Chainable 96 | 97 | /** 98 | * A query command that applies the given callback to the subject. 99 | * @see https://github.com/bahmutov/cypress-map 100 | * @param fn Callback function to call 101 | * @example 102 | * cy.wrap(2).apply(double).should('equal', 4) 103 | */ 104 | apply(fn: Function): Chainable 105 | /** 106 | * Applies the given function to the arguments and subject 107 | * The subject is **the last argument**. 108 | * @example 109 | * cy.wrap(2).apply(Cypress._.add, 4).should('equal', 6) 110 | */ 111 | apply(fn: Function, ...arguments: any[]): Chainable 112 | 113 | /** 114 | * A query command that applies the given callback to the subject. 115 | * Without arguments works the same as `apply`. 116 | * @see https://github.com/bahmutov/cypress-map 117 | * @param fn Callback function to call 118 | * @example 119 | * cy.wrap(2).applyRight(double).should('equal', 4) 120 | */ 121 | applyRight(fn: Function): Chainable 122 | /** 123 | * Applies the given function to the arguments and subject 124 | * The subject is **the first argument**. 125 | * @example 126 | * cy.wrap(8).applyRight(Cypress._.subtract, 4).should('equal', 4) 127 | */ 128 | applyRight(fn: Function, ...arguments: any[]): Chainable 129 | 130 | /** 131 | * Applies the given function to the arguments and the first item. 132 | * The first item from the current subject is **the last argument**. 133 | * @example 134 | * cy.wrap([2, 3]).applyToFirst(Cypress._.add, 4).should('equal', 6) 135 | */ 136 | applyToFirst(fn: Function, ...arguments: any[]): Chainable 137 | 138 | /** 139 | * Calls the specified method on the first item in the current subject. 140 | * @example 141 | * cy.get(selector).invokeFirst('getBoundingClientRect') 142 | */ 143 | invokeFirst( 144 | methodName: string, 145 | ...arguments: any[] 146 | ): Chainable 147 | 148 | /** 149 | * Applies the given function to the arguments and the first item. 150 | * The first item from the current subject is **the last argument**. 151 | * @example 152 | * cy.wrap([8, 1]).applyToFirstRight(Cypress._.subtract, 4).should('equal', 4) 153 | */ 154 | applyToFirstRight( 155 | fn: Function, 156 | ...arguments: any[] 157 | ): Chainable 158 | 159 | /** 160 | * Creates a callback to apply to the subject by partially applying known arguments. 161 | * @see https://github.com/bahmutov/cypress-map 162 | * @param fn Callback function to call 163 | * @param knownArguments The first argument(s) to the callback 164 | * @example 165 | * cy.wrap(2).partial(Cypress._.add, 4).should('equal', 6) 166 | */ 167 | partial( 168 | fn: Function, 169 | ...knownArguments: unknown[] 170 | ): Chainable 171 | 172 | /** 173 | * A query command that returns the first element / item from the subject. 174 | * @see https://github.com/bahmutov/cypress-map 175 | * @example 176 | * cy.get('...').primo() 177 | * @example 178 | * cy.wrap([1, 2, 3]).primo().should('equal', 1) 179 | */ 180 | primo(): Chainable 181 | 182 | /** 183 | * Returns the property of the object or DOM element, skipping through jQuery abstraction. 184 | * @see https://github.com/bahmutov/cypress-map 185 | * @param name The property name to yield 186 | * @example 187 | * cy.get('#items li.matching').last().prop('ariaLabel') 188 | */ 189 | prop(name: string): Chainable 190 | 191 | /** 192 | * Transforms the named property inside the current subjet 193 | * by passing it through the given callback 194 | * @see https://github.com/bahmutov/cypress-map 195 | * @param prop The name of the property you want to update 196 | * @param callback The function that receives the property value and returns the updated value 197 | * @example 198 | * cy.wrap({ age: '20' }).update('age', Number).should('deep.equal', {age: 20}) 199 | */ 200 | update(prop: string, callback: Function): Chainable 201 | 202 | /** 203 | * Returns an object or DOM element from the collection at index K. 204 | * Returns elements from the end of the collection for negative K. 205 | * @see https://github.com/bahmutov/cypress-map 206 | * @param index The index of the element 207 | * @example 208 | * cy.get('li').at(0) // the first DOM element 209 | * @example 210 | * cy.wrap([...]).at(-1) // the last item in the array 211 | */ 212 | at(index: number): Chainable 213 | 214 | /** 215 | * Returns a randomly picked item from the current subject. 216 | * Uses `_.sample` under the hood. 217 | * @param n Maximum number of items to pick, 1 by default 218 | * @see https://github.com/bahmutov/cypress-map 219 | * @example 220 | * cy.get('li').sample() // one of the list elements 221 | * @example 222 | * cy.wrap([...]).sample() // a random item from the array 223 | */ 224 | sample(n?: number): Chainable 225 | 226 | /** 227 | * Yields the second element or array item. 228 | * @example 229 | * cy.get('li').second() // the second DOM element 230 | */ 231 | second(): Chainable 232 | 233 | /** 234 | * Yields the third element or array item. 235 | * @example 236 | * cy.get('li').third() // the third DOM element 237 | */ 238 | third(): Chainable 239 | 240 | /** 241 | * Prints the current subject and yields it to the next command or assertion. 242 | * @see https://github.com/bahmutov/cypress-map 243 | * @see https://github.com/davidchambers/string-format 244 | * @param format Optional format string, supports "%" and "{}" notation 245 | * @example 246 | * cy.wrap(42) 247 | * .print('the answer is %d') 248 | * .should('equal', 42) 249 | * @example 250 | * cy.wrap({ name: 'Joe' }).print('person %o') 251 | * @example 252 | * cy.wrap({ name: 'Joe' }).print('person {}') 253 | * @example 254 | * cy.wrap({ name: 'Joe' }).print('person {0}') 255 | * @example 256 | * cy.wrap({ name: { first: 'Joe' } }).print('Hello, {0.name.first}') 257 | * @example 258 | * cy.wrap(arr).print('array length {0.length}') 259 | */ 260 | print(format?: string | Function): Chainable 261 | 262 | /** 263 | * Collects all cells from the table subject into a 2D array of strings. 264 | * You can slice the array into a smaller region, like a single row, column, 265 | * or a 2D region. 266 | * @example cy.get('table').table() 267 | * @example cy.get('table').table(0, 0, 2, 2) 268 | */ 269 | table( 270 | x?: number, 271 | y?: number, 272 | w?: number, 273 | h?: number, 274 | ): Chainable 275 | 276 | /** 277 | * Invokes the method on the current subject. 278 | * This is a COMMAND, not a query, so it won't retry, unlike the stock `cy.invoke` 279 | */ 280 | invokeOnce(methodName: string, ...args: unknown[]): Chainable 281 | 282 | /** 283 | * A query command that finds an item in the array or jQuery object. 284 | * Uses Lodash _.find under the hood. 285 | * @see https://github.com/bahmutov/cypress-map 286 | * @example 287 | * cy.get('...').findOne({ innerText: '...' }) 288 | * @example 289 | * cy.wrap([1, 2, 3]).findOne(n => n === 3).should('equal', 3) 290 | */ 291 | findOne(predicate: object | Function): Chainable 292 | 293 | /** 294 | * A query that calls `JSON.parse(JSON.parse(subject))` or entries. 295 | * When using `entries`, it calls `Object.entries` 296 | * then constructs the object again using `Object.fromEntries`. 297 | * @see https://github.com/bahmutov/cypress-map 298 | * @param conversionType Json by default, could be 'entries' 299 | * @example 300 | * cy.get('selector') 301 | * // yields DOMStringMap 302 | * .should('have.prop', 'dataset') 303 | * .toPlainObject() 304 | * .should('deep.include', { ... }) 305 | */ 306 | toPlainObject( 307 | conversionType?: 'json' | 'entries', 308 | ): Chainable 309 | 310 | /** 311 | * Calls the given constructor function with "new" keyword 312 | * and the current subject as the only argument. 313 | */ 314 | make(constructorFunction: Function): Chainable 315 | 316 | /** 317 | * Saves current subject in `Cypress.env` object. 318 | * Note: Cypress.env object is reset before the spec run, 319 | * but the changed values are passed from test to test. 320 | * @see https://github.com/bahmutov/cypress-map 321 | * @example 322 | * cy.wrap('hello').asEnv('greeting') 323 | */ 324 | asEnv(name: string): Chainable 325 | 326 | /** 327 | * Queries each selector and returns the found elements _in the specified order_. 328 | * Retries if the elements are not found for any of the selectors. 329 | * Supports parent subject. 330 | * @example cy.getInOrder('h1', 'h2', 'h3') 331 | * @example cy.get('tr').getInOrder('td', 'th') 332 | */ 333 | getInOrder(...selector: string[]): Chainable> 334 | /** 335 | * Queries each selector and returns the found elements _in the specified order_. 336 | * Retries if the elements are not found for any of the selectors. 337 | * Supports parent subject. 338 | * @example cy.getInOrder(['h1', 'h2', 'h3']) 339 | * @example cy.get('tr').getInOrder(['td', 'th']) 340 | */ 341 | getInOrder(selectors: string[]): Chainable> 342 | 343 | /** 344 | * Finds the elements using the parent selector, 345 | * then for each finds the children using the child selectors in order. 346 | * Yields the array of arrays of text values. 347 | */ 348 | elements( 349 | parent: string, 350 | ...children: string[] 351 | ): Chainable 352 | 353 | /** 354 | * Query the element for N milliseconds to see if its text stays the same. 355 | * If the text changes, reset the timer. Yields the original element. 356 | * @example cy.get('h1').stable('text', 1000) 357 | */ 358 | stable( 359 | type: StableType, 360 | ms?: number, 361 | options?: CyOptions, 362 | ): Chainable> 363 | /** 364 | * Query the element for N milliseconds to see if its CSS property stays the same. 365 | * If the property changes, reset the timer. Yields the original element. 366 | * @example cy.get('h1').stable('css', 'background-color', 1000) 367 | */ 368 | stable( 369 | type: 'css', 370 | param: string, 371 | ms?: number, 372 | options?: CyOptions, 373 | ): Chainable> 374 | 375 | /** 376 | * Checks if the element is detached from the DOM, 377 | * auto-retries until the element is detached. 378 | * @warning Experimental. 379 | * @example cy.detaches('#name2') 380 | */ 381 | detaches(selector: string): void 382 | 383 | /** 384 | * Computes the object with the different properties from the current subject 385 | * and the given expected object. If there are no differences, yields the `{}` 386 | * @example cy.wrap({ name: 'Joe', age: 42 }).difference({ name: 'Joe' }) 387 | * // { age: { expected: undefined, actual: 42 } } 388 | */ 389 | difference(expected: Object): Chainable 390 | } 391 | 392 | interface Chainer { 393 | /** 394 | * Chai assertion that gets the text from the current subject element 395 | * and compares it to the given text value or against a regular expression. 396 | * @example cy.get('#name').should('read', 'Joe Smith') 397 | * @example cy.get('#name').should('read', /Joe/) 398 | * @see https://github.com/bahmutov/cypress-map 399 | */ 400 | (chainer: 'read', text: string | RegExp): Chainable 401 | 402 | /** 403 | * Chai assertion that gets the text from the current subject elements 404 | * and compares them to the given text values or regular expressions. 405 | * @example cy.get('#ages').should('read', ['20', '35', '15']) 406 | * @example cy.get('#ages').should('read', ['20', /^\d+$/, '15']) 407 | * @see https://github.com/bahmutov/cypress-map 408 | */ 409 | (chainer: 'read', texts: (string | RegExp)[]): Chainable 410 | 411 | /** 412 | * Checks the presence of the given property in the current subject object. 413 | * Yields the original subject. 414 | * Supports deep nested properties using dot notation. 415 | * Supports arrays and array indexes. 416 | * 417 | * @example cy.wrap({ name: 'Joe' }).should('possess', 'name') 418 | * @example 419 | * cy.wrap({ user: { name: 'Joe' } }) 420 | * .should('possess', 'user.name') 421 | * @example 422 | * cy.wrap({ users: ['Joe', 'Jane'] }) 423 | * .should('possess', 'users[0]') 424 | * 425 | * @see https://github.com/bahmutov/cypress-map 426 | */ 427 | (chainer: 'possess', propertyName: string): Chainable 428 | 429 | /** 430 | * Checks the presence of the given property with the given value 431 | * in the current subject object against the given predicate function. 432 | * Yields the original subject. 433 | * Supports deep nested properties using dot notation. 434 | * Supports arrays and array indexes, brackets are optional 435 | * 436 | * @example 437 | * const isDrinkingAge = (years) => years > 21 438 | * cy.wrap({ age: 42 }).should('possess', 'age', isDrinkingAge) 439 | * 440 | * @see https://github.com/bahmutov/cypress-map 441 | */ 442 | ( 443 | chainer: 'possess', 444 | propertyName: string, 445 | value: Function, 446 | ): Chainable 447 | 448 | /** 449 | * Checks the presence of the given property with the given value 450 | * in the current subject object. 451 | * Yields the original subject. 452 | * Supports deep nested properties using dot notation. 453 | * Supports arrays and array indexes, brackets are optional 454 | * 455 | * @example cy.wrap({ name: 'Joe' }).should('possess', 'name', 'Joe') 456 | * @example 457 | * cy.wrap({ user: { name: 'Joe' } }) 458 | * .should('possess', 'user.name', 'Joe') 459 | * @example 460 | * cy.wrap({ users: [{ name: 'Joe' }, { name: 'Jane' }] }) 461 | * .should('possess', 'users[0].name', 'Joe') 462 | * .and('possess', 'users.1.name', 'Jane') 463 | * 464 | * @see https://github.com/bahmutov/cypress-map 465 | */ 466 | ( 467 | chainer: 'possess', 468 | propertyName: string, 469 | value: unknown, 470 | ): Chainable 471 | 472 | /** 473 | * Checks if the current subject is an array of unique values. 474 | * @example cy.wrap([1, 2, 3]).should('be.unique') 475 | * @example cy.wrap([1, 2, 2]).should('not.be.unique') 476 | * @see https://github.com/bahmutov/cypress-map 477 | */ 478 | (chainer: 'unique'): Chainable 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /src/commands/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | require('./assertions') 4 | require('./apply') 5 | require('./as-env') 6 | require('./at') 7 | require('./detaches') 8 | require('./difference') 9 | require('./find-one') 10 | require('./get-in-order') 11 | require('./invoke-once') 12 | require('./make') 13 | require('./map-chain') 14 | require('./map-invoke') 15 | require('./map') 16 | require('./partial') 17 | require('./primo') 18 | require('./print') 19 | require('./prop') 20 | require('./reduce') 21 | require('./sample') 22 | require('./second') 23 | require('./stable') 24 | require('./table') 25 | require('./tap') 26 | require('./third') 27 | require('./to-plain-object') 28 | require('./update') 29 | require('./version-check') 30 | require('./invoke-first') 31 | require('./elements') 32 | -------------------------------------------------------------------------------- /src/commands/invoke-first.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('invokeFirst', function (methodName, ...args) { 6 | let message = methodName 7 | if (args.length) { 8 | message += ' ' + args.map((x) => JSON.stringify(x)).join(', ') 9 | } 10 | const log = Cypress.log({ name: 'invokeFirst', message }) 11 | 12 | return ($el) => { 13 | const first = $el[0] 14 | return first[methodName].apply(first, args) 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /src/commands/invoke-once.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerCommand } = require('./utils') 4 | 5 | registerCommand( 6 | 'invokeOnce', 7 | { prevSubject: true }, 8 | (subject, methodName, ...args) => { 9 | let message = methodName 10 | if (args.length) { 11 | message += ' ' + args.map(JSON.stringify).join(',') 12 | } 13 | 14 | const log = Cypress.log({ name: 'invokeOnce', message }) 15 | 16 | if (typeof subject[methodName] !== 'function') { 17 | throw new Error( 18 | `Cannot find method ${methodName} on the current subject`, 19 | ) 20 | } 21 | 22 | return subject[methodName](...args) 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /src/commands/make.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('make', (constructorFn) => { 6 | if (typeof constructorFn !== 'function') { 7 | throw new Error('Expected a function') 8 | } 9 | 10 | const log = Cypress.log({ 11 | name: 'make', 12 | message: constructorFn.name, 13 | }) 14 | 15 | return (subject) => { 16 | return new constructorFn(subject) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/commands/map-chain.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerCommand } = require('./utils') 4 | 5 | registerCommand('mapChain', { prevSubject: 'Array' }, (list, fn) => { 6 | if (!Array.isArray(list)) { 7 | throw new Error('Expected cy.mapChain subject to be an array') 8 | } 9 | if (!list.length) { 10 | // if there are items in the list 11 | // can quickly move on 12 | return [] 13 | } 14 | 15 | const results = [] 16 | 17 | const produceValue = (k) => { 18 | return cy 19 | .wrap(null, { log: false }) 20 | .then(() => fn(list[k], k)) 21 | .then((result) => { 22 | results.push(result) 23 | if (k >= list.length - 1) { 24 | // done 25 | } else { 26 | return produceValue(k + 1) 27 | } 28 | }) 29 | } 30 | 31 | // make sure we put the possible promises into the command chain 32 | return cy 33 | .wrap(results, { log: false }) 34 | .then(() => produceValue(0), { log: false }) 35 | .then(() => results) 36 | }) 37 | -------------------------------------------------------------------------------- /src/commands/map-invoke.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('mapInvoke', function (methodName, ...args) { 6 | if (args.length > 0) { 7 | const lastArgument = args.at(-1) 8 | if ( 9 | lastArgument && 10 | Cypress._.isFinite(lastArgument.timeout) && 11 | lastArgument.timeout > 0 12 | ) { 13 | // make sure this query command respects the timeout option 14 | this.set('timeout', lastArgument.timeout) 15 | } 16 | } 17 | 18 | let message = methodName 19 | if (args.length) { 20 | message += ' ' + args.map((x) => JSON.stringify(x)).join(', ') 21 | } 22 | const log = Cypress.log({ name: 'mapInvoke', message }) 23 | 24 | return (list) => 25 | Cypress._.map(list, (item) => item[methodName].apply(item, args)) 26 | }) 27 | -------------------------------------------------------------------------------- /src/commands/map.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { 4 | registerQuery, 5 | findTimeout, 6 | isArrayOfStrings, 7 | } = require('./utils') 8 | 9 | const repoUrl = 'https://github.com/bahmutov/cypress-map' 10 | const repoLink = `[${repoUrl}](${repoUrl})` 11 | 12 | registerQuery('map', function (fnOrProperty, options = {}) { 13 | const timeout = findTimeout(this, options) 14 | 15 | // make sure this query command respects the timeout option 16 | this.set('timeout', timeout) 17 | 18 | if (isArrayOfStrings(fnOrProperty)) { 19 | // the user wants to pick the listed properties from the subject 20 | const message = fnOrProperty.join(', ') 21 | 22 | const log = 23 | options.log !== false && 24 | Cypress.log({ name: 'map', message, timeout }) 25 | } else if (Cypress._.isPlainObject(fnOrProperty)) { 26 | Object.keys(fnOrProperty).forEach((key) => { 27 | if (typeof fnOrProperty[key] !== 'function') { 28 | throw new Error(`Expected ${key} to be a function`) 29 | } 30 | }) 31 | 32 | const message = Cypress._.map(fnOrProperty, (fn, key) => { 33 | return key + '=>' + (fn.name ? fn.name : '?') 34 | }).join(', ') 35 | 36 | const log = 37 | options.log !== false && 38 | Cypress.log({ name: 'map', message, timeout }) 39 | } else { 40 | const message = 41 | typeof fnOrProperty === 'string' 42 | ? fnOrProperty 43 | : fnOrProperty.name 44 | 45 | const log = 46 | options.log !== false && 47 | Cypress.log({ name: 'map', message, timeout }) 48 | } 49 | 50 | return ($el) => { 51 | if (Cypress._.isString($el)) { 52 | throw new Error( 53 | `cy.map is not meant to work with a string subject, did you mean cy.apply?\n${repoLink}`, 54 | ) 55 | } 56 | if (Cypress._.isNumber($el)) { 57 | throw new Error( 58 | `cy.map is not meant to work with a number subject, did you mean cy.apply?\n${repoLink}`, 59 | ) 60 | } 61 | if (Cypress._.isBoolean($el)) { 62 | throw new Error( 63 | `cy.map is not meant to work with a boolean subject, did you mean cy.apply?\n${repoLink}`, 64 | ) 65 | } 66 | 67 | if (isArrayOfStrings(fnOrProperty)) { 68 | if (Array.isArray($el)) { 69 | // map over the array elements 70 | return $el.map((item) => { 71 | return fnOrProperty.reduce((acc, key) => { 72 | if (key.includes('.')) { 73 | // deep property pick 74 | // use the last part of the key as the name 75 | const name = key.split('.').pop() 76 | const value = Cypress._.get(item, key) 77 | acc[name] = value 78 | } else if (!(key in item)) { 79 | throw new Error(`Cannot find property ${key}`) 80 | } else { 81 | acc[key] = item[key] 82 | } 83 | return acc 84 | }, {}) 85 | }) 86 | } 87 | const result = {} 88 | fnOrProperty.forEach((key) => { 89 | if (key.includes('.')) { 90 | // deep property pick 91 | // use the last part of the key as the name 92 | const name = key.split('.').pop() 93 | const value = Cypress._.get($el, key) 94 | result[name] = value 95 | } else { 96 | if (!(key in $el)) { 97 | throw new Error(`Cannot find property ${key}`) 98 | } 99 | result[key] = $el[key] 100 | } 101 | }) 102 | return result 103 | } else if (Cypress._.isPlainObject(fnOrProperty)) { 104 | const result = { ...$el } 105 | Object.keys(fnOrProperty).forEach((key) => { 106 | if (!(key in $el)) { 107 | throw new Error(`Cannot find property ${key}`) 108 | } 109 | if (typeof fnOrProperty[key] !== 'function') { 110 | throw new Error(`Expected ${key} to be a function`) 111 | } 112 | result[key] = fnOrProperty[key]($el[key]) 113 | }) 114 | return result 115 | } else { 116 | // use a spread so that the result is an array 117 | // and used the Array constructor in the current iframe 118 | // so it passes the "instanceOf Array" assertion 119 | const list = [ 120 | ...Cypress._.map($el, (item) => 121 | typeof fnOrProperty === 'string' 122 | ? Cypress._.get(item, fnOrProperty) 123 | : fnOrProperty(item), 124 | ), 125 | ] 126 | return list 127 | } 128 | } 129 | }) 130 | -------------------------------------------------------------------------------- /src/commands/partial.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('partial', (callback, ...knownArguments) => { 6 | if (typeof callback !== 'function') { 7 | throw new Error('Expected a function to partially apply') 8 | } 9 | 10 | const applied = callback.bind(null, ...knownArguments) 11 | const log = Cypress.log({ 12 | name: 'partial', 13 | message: `${callback.name} with ${knownArguments.join(',')}`, 14 | }) 15 | 16 | return (subject) => { 17 | log.set({ 18 | $el: subject, 19 | }) 20 | 21 | return applied(subject) 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/commands/primo.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('primo', () => { 6 | const log = Cypress.log({ name: 'primo' }) 7 | 8 | return (subject) => { 9 | if (Cypress.dom.isJquery(subject)) { 10 | return subject[0] 11 | } 12 | if (Array.isArray(subject)) { 13 | return subject[0] 14 | } 15 | throw new Error('Not sure how to pick the first item') 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /src/commands/print.js: -------------------------------------------------------------------------------- 1 | // to avoid relying on old polyfills for node "format" 2 | // use a custom formatter plus our own code 3 | const format = require('string-format') 4 | 5 | function formatTitle(pattern, x) { 6 | if (pattern.includes('{}') || pattern.includes('{0}')) { 7 | x = JSON.stringify(x) 8 | } 9 | if (pattern.includes('%d')) { 10 | return pattern.replace('%d', x) 11 | } 12 | if (pattern.includes('%o')) { 13 | return pattern.replace('%o', JSON.stringify(x)) 14 | } 15 | return format(pattern, x) 16 | } 17 | 18 | const { registerQuery } = require('./utils') 19 | 20 | registerQuery('print', (formatPattern) => { 21 | const log = Cypress.log({ name: 'print', message: '' }) 22 | 23 | if (typeof formatPattern === 'string') { 24 | return (subject) => { 25 | const formatted = formatTitle(formatPattern, subject) 26 | log.set('message', formatted) 27 | return subject 28 | } 29 | } else if (typeof formatPattern === 'function') { 30 | return (subject) => { 31 | let formatted = formatPattern(subject) 32 | if (typeof formatted !== 'string') { 33 | formatted = JSON.stringify(formatted) 34 | } 35 | log.set('message', formatted) 36 | return subject 37 | } 38 | } else { 39 | return (subject) => { 40 | log.set('message', JSON.stringify(subject)) 41 | return subject 42 | } 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/commands/prop.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('prop', (propertyName) => { 6 | const log = Cypress.log({ name: 'prop', message: propertyName }) 7 | 8 | return (subject) => { 9 | if (Cypress.dom.isJquery(subject)) { 10 | return subject.prop(propertyName) 11 | } 12 | return subject[propertyName] 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/commands/reduce.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('reduce', (fn, initialValue) => { 6 | if (typeof fn !== 'function') { 7 | throw new Error('Expected a function to apply') 8 | } 9 | 10 | let message = fn.name 11 | if (typeof initialValue !== 'undefined') { 12 | message += ', ' + initialValue 13 | } 14 | const log = Cypress.log({ name: 'reduce', message }) 15 | 16 | // see https://lodash.com/docs/ _.reduce documentation 17 | if (typeof initialValue !== 'undefined') { 18 | return (list) => Cypress._.reduce(list, fn, initialValue) 19 | } else { 20 | return (list) => Cypress._.reduce(list, fn) 21 | } 22 | }) 23 | 24 | // hmm, should we have ".max" and ".min" helper query commands? 25 | -------------------------------------------------------------------------------- /src/commands/sample.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('sample', (n = 1) => { 6 | if (n < 1) { 7 | throw new Error(`Sample size should be positive, was ${n}`) 8 | } 9 | 10 | if (n === 1) { 11 | const log = Cypress.log({ name: 'sample' }) 12 | 13 | return (subject) => { 14 | // console.log('pick 1 sample') 15 | if (Cypress.dom.isJquery(subject)) { 16 | const randomElement = Cypress._.sample(subject.toArray()) 17 | // wrap into jQuery object so other commands 18 | // can be attached, like cy.click 19 | return Cypress.$(randomElement) 20 | } 21 | 22 | // console.log('picked sample', sample) 23 | const currCommand = cy.state('current').attributes 24 | if ( 25 | currCommand.name === 'as' && 26 | currCommand.args[1]?.type === 'static' 27 | ) { 28 | // console.log( 29 | // 'current command', 30 | // cy.state('current').attributes.name, 31 | // ) 32 | if ( 33 | cy.state('current').attributes?.prev?.attributes?.name === 34 | 'sample' 35 | ) { 36 | // console.log(cy.state('current').attributes.prev.attributes) 37 | const prevSubject = 38 | cy.state('current').attributes.prev.attributes.subject 39 | // console.log('returning prev sampled subject', prevSubject) 40 | 41 | return prevSubject 42 | } 43 | } 44 | const sample = Cypress._.sample(subject) 45 | // console.log(cy.state('current').attributes.name) 46 | // console.log(cy.state('current').attributes) 47 | // console.log('sampled subject', sample) 48 | // debugger 49 | return sample 50 | } 51 | } else { 52 | const log = Cypress.log({ name: 'sample', message: String(n) }) 53 | 54 | return (subject) => { 55 | // console.log('pick N samples') 56 | if (Cypress.dom.isJquery(subject)) { 57 | const randomElement = Cypress._.sampleSize( 58 | subject.toArray(), 59 | n, 60 | ) 61 | // wrap into jQuery object so other commands 62 | // can be attached, like cy.click 63 | return Cypress.$(randomElement) 64 | } 65 | 66 | return Cypress._.sampleSize(subject, n) 67 | } 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /src/commands/second.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('second', () => { 6 | const log = Cypress.log({ name: 'second' }) 7 | 8 | return (subject) => { 9 | if (Cypress.dom.isJquery(subject)) { 10 | return subject[1] 11 | } 12 | 13 | if (Array.isArray(subject)) { 14 | return subject[1] 15 | } 16 | 17 | throw new Error('Expected an array or jQuery object') 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /src/commands/stable.ts: -------------------------------------------------------------------------------- 1 | import { registerQuery } from './utils' 2 | 3 | export type StableType = 'text' | 'value' | 'element' | 'css' 4 | 5 | const validStableTypes = ['text', 'value', 'element', 'css'] 6 | 7 | // set to console.log if you want to debug the command 8 | const logger = Cypress._.noop 9 | // const logger = console.log 10 | 11 | function stableCss( 12 | param: string, 13 | ms: number = 1000, 14 | options: CyOptions = { log: true }, 15 | ) { 16 | const shouldLog = 'log' in options ? options.log : true 17 | 18 | // make sure this query command respects the timeout option 19 | const timeout = 20 | 'timeout' in options 21 | ? options.timeout 22 | : Cypress.config('defaultCommandTimeout') 23 | this.set('timeout', timeout) 24 | 25 | const message = `stable ${param} for ${ms}ms` 26 | const log = 27 | shouldLog && 28 | Cypress.log({ 29 | name: `stable css`, 30 | message, 31 | timeout, 32 | }) 33 | logger(log) 34 | 35 | let started = null 36 | let initialValue = null 37 | let initialAt = null 38 | return ($el) => { 39 | if (initialValue === null) { 40 | started = +new Date() 41 | initialValue = $el.css(param) 42 | initialAt = started 43 | logger('started with CSS value %o', initialValue) 44 | throw new Error('start') 45 | } 46 | if ($el.css(param) === initialValue) { 47 | const now = +new Date() 48 | if (now - started > ms) { 49 | logger( 50 | 'after %dms stable CSS %s %o', 51 | now - started, 52 | param, 53 | initialValue, 54 | ) 55 | if (shouldLog) { 56 | log.set('consoleProps', () => { 57 | return { 58 | time: now - started, 59 | duration: now - initialAt, 60 | css: param, 61 | result: initialValue, 62 | } 63 | }) 64 | } 65 | // yield the original element 66 | // so we can chain more commands and assertions 67 | return $el 68 | } else { 69 | throw new Error('waiting') 70 | } 71 | } else { 72 | started = +new Date() 73 | initialValue = $el.css(param) 74 | logger('CSS value %s changed to %o', param, initialValue) 75 | throw new Error('reset') 76 | } 77 | } 78 | } 79 | 80 | function stableNonCss( 81 | type: StableType, 82 | ms: number = 1000, 83 | options: CyOptions = { log: true }, 84 | ) { 85 | if (!validStableTypes.includes(type)) { 86 | throw new Error(`unknown cy.stable type "${type}"`) 87 | } 88 | 89 | const shouldLog = 'log' in options ? options.log : true 90 | 91 | // make sure this query command respects the timeout option 92 | const timeout = 93 | 'timeout' in options 94 | ? options.timeout 95 | : Cypress.config('defaultCommandTimeout') 96 | this.set('timeout', timeout) 97 | 98 | const message = `stable for ${ms}ms` 99 | const log = 100 | shouldLog && 101 | Cypress.log({ 102 | name: `stable ${type}`, 103 | message, 104 | timeout, 105 | }) 106 | logger(log) 107 | 108 | if (type === 'text') { 109 | let started = null 110 | let initialText = null 111 | let initialAt = null 112 | return ($el) => { 113 | if (initialText === null) { 114 | started = +new Date() 115 | initialText = $el.text() 116 | initialAt = started 117 | logger('started with text "%s"', initialText) 118 | throw new Error('start') 119 | } 120 | if ($el.text() === initialText) { 121 | const now = +new Date() 122 | if (now - started > ms) { 123 | logger( 124 | 'after %dms stable text "%s"', 125 | now - started, 126 | initialText, 127 | ) 128 | if (shouldLog) { 129 | log.set('consoleProps', () => { 130 | return { 131 | time: now - started, 132 | duration: now - initialAt, 133 | result: initialText, 134 | } 135 | }) 136 | } 137 | // yield the original element 138 | // so we can chain more commands and assertions 139 | return $el 140 | } else { 141 | throw new Error('waiting') 142 | } 143 | } else { 144 | started = +new Date() 145 | initialText = $el.text() 146 | logger('text changed to "%s"', initialText) 147 | throw new Error('reset') 148 | } 149 | } 150 | } else if (type === 'value') { 151 | let started = null 152 | let initialValue = null 153 | let initialAt = null 154 | return ($el) => { 155 | if (initialValue === null) { 156 | started = +new Date() 157 | initialValue = $el.val() 158 | initialAt = started 159 | logger('started with value %o', initialValue) 160 | throw new Error('start') 161 | } 162 | if ($el.val() === initialValue) { 163 | const now = +new Date() 164 | if (now - started > ms) { 165 | logger( 166 | 'after %dms stable val %o', 167 | now - started, 168 | initialValue, 169 | ) 170 | if (shouldLog) { 171 | log.set('consoleProps', () => { 172 | return { 173 | time: now - started, 174 | duration: now - initialAt, 175 | result: initialValue, 176 | } 177 | }) 178 | } 179 | // yield the original element 180 | // so we can chain more commands and assertions 181 | return $el 182 | } else { 183 | throw new Error('waiting') 184 | } 185 | } else { 186 | started = +new Date() 187 | initialValue = $el.val() 188 | logger('value changed to %o', initialValue) 189 | throw new Error('reset') 190 | } 191 | } 192 | } else if (type === 'element') { 193 | let started = null 194 | let initialElement = null 195 | let initialAt = null 196 | return ($el) => { 197 | if (initialElement === null) { 198 | if ($el.length !== 1) { 199 | throw new Error('Expected one element to check if stable') 200 | } 201 | started = +new Date() 202 | initialElement = $el[0] 203 | initialAt = started 204 | throw new Error('start') 205 | } 206 | if ($el[0] === initialElement) { 207 | const now = +new Date() 208 | if (now - started > ms) { 209 | logger('after %dms stable element', now - started) 210 | if (shouldLog) { 211 | log.set('consoleProps', () => { 212 | return { 213 | time: now - started, 214 | duration: now - initialAt, 215 | result: initialElement, 216 | } 217 | }) 218 | } 219 | // yield the original element 220 | // so we can chain more commands and assertions 221 | return $el 222 | } else { 223 | throw new Error('waiting') 224 | } 225 | } else { 226 | started = +new Date() 227 | initialElement = $el[0] 228 | logger( 229 | 'element changed to "%s"', 230 | initialElement.innerText.substring(0, 100) + '...', 231 | ) 232 | throw new Error('reset') 233 | } 234 | } 235 | } 236 | } 237 | 238 | registerQuery( 239 | 'stable', 240 | function ( 241 | type: StableType, 242 | param: string, 243 | ms: number = 1000, 244 | options: CyOptions = { log: true }, 245 | ) { 246 | if (!validStableTypes.includes(type)) { 247 | throw new Error(`unknown cy.stable type "${type}"`) 248 | } 249 | 250 | if (type === 'css') { 251 | return stableCss.call(this, param, ms, options) 252 | } else { 253 | if (arguments.length === 1) { 254 | return stableNonCss.call(this, type) 255 | } else if (arguments.length === 2) { 256 | return stableNonCss.call(this, type, param) 257 | } else if (arguments.length === 3) { 258 | return stableNonCss.call(this, type, param, ms) 259 | } 260 | } 261 | }, 262 | ) 263 | -------------------------------------------------------------------------------- /src/commands/table.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | /** 6 | * Queries the table 7 | * @param {number?} x Top left corner X index (zero based) 8 | * @param {number?} y Top left corner Y index (zero based) 9 | * @param {number?} w Number of columns 10 | * @param {number?} h Number of rows 11 | */ 12 | function cyTable(x, y, w, h) { 13 | let message 14 | if (typeof x === 'number' && typeof y === 'number') { 15 | message = `x:${x},y:${y}` 16 | } 17 | if (typeof w === 'number') { 18 | message += `,w:${w}` 19 | } 20 | if (typeof h === 'number') { 21 | message += `,h:${h}` 22 | } 23 | const log = Cypress.log({ name: 'table', message }) 24 | 25 | const wSet = typeof w === 'number' 26 | const hSet = typeof h === 'number' 27 | 28 | return ($table) => { 29 | const cells = Cypress._.map($table.find('tr'), (tr) => { 30 | return Cypress._.map(tr.children, 'innerText') 31 | }) 32 | 33 | if (typeof x === 'number' && typeof y === 'number') { 34 | if (!wSet && cells.length) { 35 | w = cells[0].length 36 | } 37 | if (!hSet) { 38 | h = cells.length 39 | } 40 | const slice = cells.slice(y, y + h).map((row) => { 41 | return row.slice(x, x + w) 42 | }) 43 | return slice 44 | } 45 | return cells 46 | } 47 | } 48 | 49 | registerQuery('table', cyTable) 50 | -------------------------------------------------------------------------------- /src/commands/tap.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('tap', (fn = console.log, label = undefined) => { 6 | if (typeof fn === 'string') { 7 | // the user passed the label only, like 8 | // cy.tap('numbers') 9 | label = fn 10 | fn = console.log 11 | } 12 | 13 | const logName = label ? label : fn.name 14 | const log = Cypress.log({ name: 'tap', message: logName }) 15 | 16 | return (subject) => { 17 | log.set({ 18 | $el: subject, 19 | }) 20 | 21 | if (typeof label === 'string') { 22 | fn(label, subject) 23 | } else { 24 | fn(subject) 25 | } 26 | return subject 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /src/commands/third.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('third', () => { 6 | const log = Cypress.log({ name: 'third' }) 7 | 8 | return (subject) => { 9 | if (Cypress.dom.isJquery(subject)) { 10 | return subject[2] 11 | } 12 | 13 | if (Array.isArray(subject)) { 14 | return subject[2] 15 | } 16 | 17 | throw new Error('Expected an array or jQuery object') 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /src/commands/to-plain-object.js: -------------------------------------------------------------------------------- 1 | const { registerQuery } = require('./utils') 2 | 3 | registerQuery('toPlainObject', (conversionType = 'json') => { 4 | const log = Cypress.log({ 5 | name: 'toPlainObject', 6 | message: conversionType, 7 | }) 8 | 9 | const jsonConversion = (subject) => { 10 | return JSON.parse(JSON.stringify(subject)) 11 | } 12 | const entriesConversion = (subject) => { 13 | return Object.fromEntries(subject.entries()) 14 | } 15 | 16 | if (conversionType === 'json') { 17 | return jsonConversion 18 | } else if (conversionType === 'entries') { 19 | return entriesConversion 20 | } else { 21 | throw new Error(`unknown conversion type: ${conversionType}`) 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/commands/update.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const { registerQuery } = require('./utils') 4 | 5 | registerQuery('update', (prop, callback) => { 6 | if (typeof callback !== 'function') { 7 | throw new Error('Expected a function to apply') 8 | } 9 | 10 | const log = Cypress.log({ 11 | name: 'update', 12 | message: `${prop} by ${callback.name}`, 13 | }) 14 | 15 | return (subject) => { 16 | log.set({ 17 | $el: subject, 18 | }) 19 | 20 | return { ...subject, [prop]: callback(subject[prop]) } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/commands/utils.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @see https://on.cypress.io/custom-commands 5 | */ 6 | function registerQuery(name, fn) { 7 | // prevent double registration attempt 8 | if (!(name in cy)) { 9 | return Cypress.Commands.addQuery(name, fn) 10 | } 11 | } 12 | 13 | function registerCommand(name, options, fn) { 14 | // prevent double registration attempt 15 | if (!(name in cy)) { 16 | if (typeof options === 'function') { 17 | return Cypress.Commands.add(name, fn) 18 | } else { 19 | return Cypress.Commands.add(name, options, fn) 20 | } 21 | } 22 | } 23 | 24 | /** 25 | * Finds the timeout option from this command or from its parent command 26 | */ 27 | function findTimeout(cmd, options = {}) { 28 | if (Cypress._.isFinite(options.timeout)) { 29 | return options.timeout 30 | } 31 | 32 | const defaultTimeout = Cypress.config('defaultCommandTimeout') 33 | if (!cmd) { 34 | return defaultTimeout 35 | } 36 | const prev = cmd.attributes?.prev 37 | const prevTimeout = prev?.attributes?.timeout 38 | if (Cypress._.isFinite(prevTimeout)) { 39 | return prevTimeout 40 | } 41 | if (prev.attributes.args?.length) { 42 | const lastArg = prev.attributes.args.at(-1) 43 | if (Cypress._.isFinite(lastArg?.timeout)) { 44 | return lastArg?.timeout 45 | } 46 | } 47 | 48 | return defaultTimeout 49 | } 50 | 51 | /** 52 | * Returns true if the argument is an array of strings. 53 | * Note: an empty array is not considered an array of strings. 54 | */ 55 | function isArrayOfStrings(x) { 56 | return ( 57 | Cypress._.isArray(x) && 58 | x.length && 59 | x.every((item) => typeof item === 'string') 60 | ) 61 | } 62 | 63 | module.exports = { 64 | registerQuery, 65 | registerCommand, 66 | findTimeout, 67 | isArrayOfStrings, 68 | } 69 | -------------------------------------------------------------------------------- /src/commands/version-check.js: -------------------------------------------------------------------------------- 1 | const major = Cypress.version.split('.').map(Number)[0] 2 | if (major < 12) { 3 | throw new Error(`cypress-map requires Cypress version >= 12`) 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "ES2021"], 4 | "types": ["cypress"], 5 | "target": "ES5", 6 | "outDir": "./commands", 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | }, 10 | "include": ["src/commands/*.*"], 11 | } 12 | --------------------------------------------------------------------------------