├── .github └── workflows │ ├── badges.yml │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── cypress.config.ts ├── cypress ├── README.md ├── e2e │ ├── add.cy.ts │ ├── filters.cy.ts │ ├── hides-credentials.cy.ts │ ├── post.cy.ts │ ├── put.cy.ts │ ├── server-logs.cy.ts │ ├── shows-credentials.cy.ts │ ├── spec.cy.ts │ └── style.cy.ts ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── support │ └── e2e.ts └── tsconfig.json ├── images └── cy-api.jpg ├── package-lock.json ├── package.json ├── renovate.json ├── server-public └── test.html ├── server └── index.js ├── src ├── index.ts ├── support.ts └── types.ts └── 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-20.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@v4 22 | with: 23 | commit_message: Updated badges 24 | branch: master 25 | file_pattern: README.md 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | jobs: 4 | install-and-test: 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: Run tests 🧪 22 | # https://github.com/cypress-io/github-action 23 | uses: cypress-io/github-action@v6 24 | with: 25 | install: true 26 | build: npm run build 27 | start: npm start 28 | 29 | release: 30 | needs: [install-and-test] 31 | runs-on: ubuntu-24.04 32 | if: github.ref == 'refs/heads/master' 33 | steps: 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | 38 | - name: Checkout 🛎 39 | uses: actions/checkout@v4 40 | 41 | - name: Install everything 📦 42 | # https://github.com/cypress-io/github-action 43 | uses: cypress-io/github-action@v6 44 | with: 45 | runTests: false 46 | 47 | - name: Build dist 🏗 48 | run: npm run build 49 | 50 | - name: Semantic Release 🚀 51 | uses: cycjimmy/semantic-release-action@v4 52 | with: 53 | branch: master 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cypress/videos/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorTheme": "Snazzy Operator" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @bahmutov/cy-api 2 | 3 | [![renovate-app badge][renovate-badge]][renovate-app] ![cypress version](https://img.shields.io/badge/cypress-13.17.0-brightgreen) 4 | 5 | > Cypress custom command "cy.api" for end-to-end API testing 6 | 7 | This command makes HTTP requests to external servers, then renders the input and output where the web application usually is in the Cypress Test Runner. If there are server-side logs using [@bahmutov/all-logs][all-logs], this command fetches them and renders too. Here is typical output: 8 | 9 | ![`cy.api` in action](images/cy-api.jpg) 10 | 11 | ## Install 12 | 13 | ``` 14 | npm install --save-dev @bahmutov/cy-api 15 | ``` 16 | 17 | or 18 | 19 | ``` 20 | yarn add -D @bahmutov/cy-api 21 | ``` 22 | 23 | Add the following line to your Cypress support file 24 | 25 | ```js 26 | // usually cypress/support/index.js 27 | import '@bahmutov/cy-api' 28 | ``` 29 | 30 | This will add a new command `cy.api` for making API requests. 31 | 32 | ## Configuration 33 | 34 | | var env | default value | description | 35 | | ---------------------------- | ------------- | ------------------------------------- | 36 | | CYPRESS_API_MESSAGES | true | Show and make call to api server logs | 37 | | CYPRESS_API_SHOW_CREDENTIALS | false | Show authentication password | 38 | 39 | By default `cy.api` print response in the browser. To have the same behaviour as `cy.request` and use `cy.visit` normally, you need to desactivate `apiDisplayRequest` : 40 | 41 | ```js 42 | it('my test without displaying request', { apiDisplayRequest: false }, () => { 43 | cy.api({ 44 | url: '/', 45 | }) 46 | }) 47 | ``` 48 | 49 | ## TypeScript 50 | 51 | If your using TypeScript with Cypress, you can add type in your `tsconfig.json` 52 | 53 | ```json 54 | { 55 | "compilerOptions": { 56 | "types": ["cypress", "@bahmutov/cy-api"] 57 | } 58 | } 59 | ``` 60 | 61 | ## Examples 62 | 63 | - [bahmutov/server-logs-example](https://github.com/bahmutov/server-logs-example) 64 | 65 | ### Courses 66 | 67 | - 🎓 [Cypress Plugins](https://cypress.tips/courses/cypress-plugins/) 68 | - [Lesson f2: Write an API test using the cy.api command](https://cypress.tips/courses/cypress-plugins/lessons/f2) 69 | 70 | ## More info 71 | 72 | - Read [Black box API testing with server logs](https://glebbahmutov.com/blog/api-testing-with-server-logs/) 73 | - Read [Capture all the logs](https://glebbahmutov.com/blog/capture-all-the-logs/) and [@bahmutov/all-logs][all-logs] module. 74 | - Read [You Should Test More Using APIs](https://glebbahmutov.com/blog/test-using-apis/) 75 | - Read [Use Cypress For API Testing](https://glebbahmutov.com/blog/use-cypress-for-api-testing/) 76 | 77 | [all-logs]: https://github.com/bahmutov/all-logs 78 | 79 | ## Development 80 | 81 | - install all dependencies using `npm install` 82 | - start watching and compiling the TS source using `npm run build:watch` 83 | - from another terminal launch the local server and open Cypress using `npm run dev` 84 | - open the desired Cypress spec file 85 | 86 | ### Small print 87 | 88 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2019 89 | 90 | - [@bahmutov](https://twitter.com/bahmutov) 91 | - [glebbahmutov.com](https://glebbahmutov.com) 92 | - [blog](https://glebbahmutov.com/blog) 93 | 94 | License: MIT - do anything with the code, but don't blame me if it does not work. 95 | 96 | Support: if you find any problems with this module, email / tweet / 97 | [open issue](https://github.com/bahmutov/cy-api/issues) on Github 98 | 99 | ## MIT License 100 | 101 | Copyright (c) 2019 Gleb Bahmutov <gleb.bahmutov@gmail.com> 102 | 103 | Permission is hereby granted, free of charge, to any person 104 | obtaining a copy of this software and associated documentation 105 | files (the "Software"), to deal in the Software without 106 | restriction, including without limitation the rights to use, 107 | copy, modify, merge, publish, distribute, sublicense, and/or sell 108 | copies of the Software, and to permit persons to whom the 109 | Software is furnished to do so, subject to the following 110 | conditions: 111 | 112 | The above copyright notice and this permission notice shall be 113 | included in all copies or substantial portions of the Software. 114 | 115 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 116 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 117 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 118 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 119 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 120 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 121 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 122 | OTHER DEALINGS IN THE SOFTWARE. 123 | 124 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 125 | [renovate-app]: https://renovateapp.com/ 126 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | // We've imported your old cypress plugins here. 6 | // You may want to clean this up later by importing these. 7 | setupNodeEvents(on, config) { 8 | return require('./cypress/plugins/index.ts')(on, config) 9 | }, 10 | baseUrl: 'http://localhost:3003', 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /cypress/README.md: -------------------------------------------------------------------------------- 1 | # Cypress.io end-to-end tests 2 | 3 | [Cypress.io](https://www.cypress.io) is an open source, MIT licensed end-to-end test runner 4 | 5 | ## Folder structure 6 | 7 | These folders hold end-to-end tests and supporting files for the Cypress Test Runner. 8 | 9 | - [fixtures](fixtures) holds optional JSON data for mocking, [read more](https://on.cypress.io/fixture) 10 | - [integration](integration) holds the actual test files, [read more](https://on.cypress.io/writing-and-organizing-tests) 11 | - [plugins](plugins) allow you to customize how tests are loaded, [read more](https://on.cypress.io/plugins) 12 | - [support](support) file runs before all tests and is a great place to write or load additional custom commands, [read more](https://on.cypress.io/writing-and-organizing-tests#Support-file) 13 | 14 | ## `cypress.json` file 15 | 16 | You can configure project options in the [../cypress.json](../cypress.json) file, see [Cypress configuration doc](https://on.cypress.io/configuration). 17 | 18 | ## More information 19 | 20 | - [https://github.com/cypress.io/cypress](https://github.com/cypress.io/cypress) 21 | - [https://docs.cypress.io/](https://docs.cypress.io/) 22 | - [Writing your first Cypress test](http://on.cypress.io/intro) 23 | -------------------------------------------------------------------------------- /cypress/e2e/add.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | it('calls API methods', { viewportHeight: 2000 }, () => { 5 | // get the first random number from the server 6 | // get the second random number from the server 7 | // call server to compute the sum 8 | // confirm the sum is correct 9 | cy.api( 10 | { 11 | url: '/random-number', 12 | }, 13 | 'first number', 14 | ) 15 | .its('body.n') 16 | .should('be.within', 0, 10) 17 | .then((a) => { 18 | cy.api( 19 | { 20 | url: '/random-number', 21 | }, 22 | 'second number', 23 | ) 24 | .its('body.n') 25 | .should('be.within', 0, 10) 26 | .then((b) => { 27 | cy.api( 28 | { 29 | url: '/sum', 30 | body: { 31 | a, 32 | b, 33 | }, 34 | }, 35 | 'sum', 36 | ) 37 | .its('body.sum') 38 | .should('equal', a + b) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /cypress/e2e/filters.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Filters', () => { 2 | it('filter log', () => { 3 | cy.api( 4 | { 5 | url: '/logs' 6 | }, 7 | 'hello world' 8 | ) 9 | 10 | // All logs are here and filter checked 11 | cy.get('#check-console').should('be.checked') 12 | cy.get('#check-debug').should('be.checked') 13 | cy.get('#check-util-debuglog').should('be.checked') 14 | cy.get('#check-console-log').should('be.checked') 15 | cy.get('#check-debug-info').should('be.checked') 16 | cy.get('#check-debug-verbose').should('be.checked') 17 | cy.get('#check-util-debuglog-HELLO').should('be.checked') 18 | 19 | // specific debug logs 20 | cy.get('.debug-verbose').should('be.visible') 21 | cy.get('#check-debug-verbose').uncheck() 22 | cy.get('.debug-verbose').should('not.be.visible') 23 | cy.get('#check-debug-verbose').check() 24 | cy.get('.debug-verbose').should('be.visible') 25 | 26 | // all debug logs 27 | cy.get('.debug').should('be.visible') 28 | cy.get('#check-debug').uncheck() 29 | cy.get('.debug').should('not.be.visible') 30 | cy.get('#check-debug').check() 31 | cy.get('.debug').should('be.visible') 32 | 33 | }) 34 | }) -------------------------------------------------------------------------------- /cypress/e2e/hides-credentials.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | describe('cy.api', () => { 5 | it('mask credentials bearer and password', () => { 6 | cy.api({ 7 | url: '/', 8 | auth: { 9 | bearer: 'bearer', 10 | username: 'login', 11 | password: 'password', 12 | }, 13 | }).then((response) => { 14 | expect(response.status).eq(200) 15 | cy.contains('"bearer": "*****"') 16 | cy.contains('"password": "*****"') 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /cypress/e2e/post.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | it('sends a POST request', () => { 5 | cy.api({ 6 | method: 'POST', 7 | url: '/json', 8 | body: { 9 | name: 'Jane', 10 | }, 11 | }) 12 | .its('body') 13 | .should('deep.equal', { name: 'Jane' }) 14 | }) 15 | -------------------------------------------------------------------------------- /cypress/e2e/put.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | it('sends a PUT request', () => { 5 | cy.api({ 6 | method: 'PUT', 7 | url: '/json', 8 | body: { 9 | name: 'Jane', 10 | }, 11 | }) 12 | .its('body') 13 | .should('deep.equal', { name: 'Jane' }) 14 | }) 15 | -------------------------------------------------------------------------------- /cypress/e2e/server-logs.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | it('shows the full message from the server logs', () => { 5 | cy.api({ 6 | method: 'POST', 7 | url: '/json', 8 | body: { 9 | name: 'Jane', 10 | }, 11 | }) 12 | .its('messages') 13 | .should('be.an', 'array') 14 | .map('message') 15 | .should('deep.equal', ['POST /json', { name: 'Jane' }]) 16 | 17 | cy.get('.cy-api-logs-messages').should('not.include.text', '[object Object]') 18 | cy.get('.cy-api-logs-messages') 19 | .find('.console.console-log') 20 | .should('read', ['console log: POST /json', 'console log: {"name":"Jane"}']) 21 | }) 22 | 23 | it('serializes multiple arguments', () => { 24 | cy.api( 25 | { 26 | url: '/sum', 27 | body: { 28 | a: 10, 29 | b: -5, 30 | }, 31 | }, 32 | 'sum', 33 | ) 34 | .its('body.sum') 35 | .should('equal', 5) 36 | 37 | cy.get('.cy-api-logs-messages') 38 | .find('.console.console-log') 39 | .should('read', [ 40 | 'console log: summing { a: 10, b: -5 }', 41 | 'console log: returning sum 5', 42 | ]) 43 | }) 44 | -------------------------------------------------------------------------------- /cypress/e2e/shows-credentials.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | describe('cy.api', () => { 5 | it( 6 | 'show credentials', 7 | { 8 | env: { 9 | API_SHOW_CREDENTIALS: true, 10 | }, 11 | }, 12 | () => { 13 | cy.api({ 14 | url: '/', 15 | auth: { 16 | bearer: 'bearer', 17 | username: 'login', 18 | password: 'password', 19 | }, 20 | }).then((response) => { 21 | expect(response.status).eq(200) 22 | cy.contains('"bearer": "bearer"') 23 | cy.contains('"password": "password"') 24 | }) 25 | }, 26 | ) 27 | 28 | it( 29 | 'show very long credentials', 30 | { 31 | env: { 32 | API_SHOW_CREDENTIALS: true, 33 | }, 34 | }, 35 | () => { 36 | cy.api({ 37 | url: '/', 38 | auth: { 39 | bearer: Cypress._.repeat('bearer_', 30), 40 | username: 'login', 41 | password: 'password', 42 | }, 43 | }).then((response) => { 44 | expect(response.status).eq(200) 45 | cy.contains('"bearer": "bearer_bearer_') 46 | cy.contains('"password": "password"') 47 | // you should be able to scroll the container horizontally 48 | }) 49 | }, 50 | ) 51 | }) 52 | -------------------------------------------------------------------------------- /cypress/e2e/spec.cy.ts: -------------------------------------------------------------------------------- 1 | // loads definition for the custom "cy.api" command 2 | /// 3 | 4 | describe('cy.api', () => { 5 | it('calls API method', () => { 6 | cy.api({ 7 | url: '/', 8 | }) 9 | }) 10 | 11 | it( 12 | 'calls API without displaying request', 13 | { apiDisplayRequest: false }, 14 | () => { 15 | cy.get('body').should('not.contain', 'MY PAGE') 16 | cy.api({ 17 | url: '/', 18 | }) 19 | cy.get('.container').should('not.contain', 'Request') 20 | cy.visit('/test.html') 21 | cy.contains('MY PAGE') 22 | cy.url().should('contain', 'test.html') 23 | cy.api( 24 | { 25 | url: '/', 26 | }, 27 | 'hello world', 28 | ) 29 | cy.contains('MY PAGE') 30 | cy.get('.container').should('not.contain', 'Request') 31 | }, 32 | ) 33 | 34 | it('calls several times', () => { 35 | const options = { url: '/' } 36 | cy.api(options, 'first') 37 | cy.api(options, 'second') 38 | cy.api(options, 'third') 39 | }) 40 | 41 | it('yields API call result', () => { 42 | cy.api( 43 | { 44 | url: '/', 45 | }, 46 | 'my hello world', 47 | ).then((response) => { 48 | expect(response).to.include.keys([ 49 | 'status', 50 | 'statusText', 51 | 'body', 52 | 'requestHeaders', 53 | 'headers', 54 | 'duration', 55 | ]) 56 | expect(response.body).to.equal('Hello World!') 57 | }) 58 | }) 59 | 60 | it('yields result that has log messages', () => { 61 | cy.api( 62 | { 63 | url: '/', 64 | }, 65 | 'hello world', 66 | ).then(({ messages }) => { 67 | console.table(messages) 68 | // filter for "console.log" messages 69 | const logs = Cypress._.filter(messages, { 70 | type: 'console', 71 | namespace: 'log', 72 | }) 73 | expect(logs, '1 console.log message').to.have.length(1) 74 | expect(logs[0]).to.deep.include({ 75 | type: 'console', 76 | namespace: 'log', 77 | message: 'processing GET /', 78 | }) 79 | }) 80 | }) 81 | 82 | it( 83 | 'yields result that has log messages with API_MESSAGES true', 84 | { 85 | env: { 86 | API_MESSAGES: true, 87 | }, 88 | }, 89 | () => { 90 | cy.api( 91 | { 92 | url: '/', 93 | }, 94 | 'hello world', 95 | ).then(({ messages }) => { 96 | console.table(messages) 97 | // filter for "console.log" messages 98 | const logs = Cypress._.filter(messages, { 99 | type: 'console', 100 | namespace: 'log', 101 | }) 102 | expect(logs, '1 console.log message').to.have.length(1) 103 | expect(logs[0]).to.deep.include({ 104 | type: 'console', 105 | namespace: 'log', 106 | message: 'processing GET /', 107 | }) 108 | }) 109 | }, 110 | ) 111 | 112 | it( 113 | 'no log messages with API_MESSAGES false', 114 | { 115 | env: { 116 | API_MESSAGES: false, 117 | }, 118 | }, 119 | () => { 120 | cy.api( 121 | { 122 | url: '/', 123 | }, 124 | 'hello world', 125 | ).then(({ messages }) => { 126 | expect(messages).to.have.length(0) 127 | }) 128 | }, 129 | ) 130 | 131 | it('mask credentials bearer', () => { 132 | cy.api({ 133 | url: '/', 134 | auth: { 135 | bearer: 'bearer', 136 | }, 137 | }).then((response) => { 138 | expect(response.status).eq(200) 139 | cy.contains('"bearer": "*****"') 140 | }) 141 | }) 142 | 143 | it('mask credentials password', () => { 144 | cy.api({ 145 | url: '/', 146 | auth: { 147 | username: 'login', 148 | password: 'password', 149 | }, 150 | }).then((response) => { 151 | expect(response.status).eq(200) 152 | cy.contains('"password": "*****"') 153 | }) 154 | }) 155 | }) 156 | -------------------------------------------------------------------------------- /cypress/e2e/style.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Response format / styling', () => { 2 | 3 | it('json response, theme color vs', () => { 4 | cy.api({ 5 | url: '/json', 6 | auth: { 7 | username: 'toto', 8 | password: 'tutu' 9 | } 10 | }).then(response => { 11 | expect(response.body).to.be.deep.eq({ 12 | "string": "string", 13 | "int": 1234, 14 | "object": { 15 | "array": [ 16 | 1, 17 | 2 18 | ] 19 | } 20 | }) 21 | }) 22 | cy.log('request colors') 23 | // red 24 | cy.get('.cy-api > div > .hljs > :nth-child(2)') 25 | .should('have.css', 'color', 'rgb(255, 0, 0)'); 26 | // brown 27 | cy.get('.cy-api > div > .hljs > :nth-child(4)') 28 | .should('have.css', 'color', 'rgb(163, 21, 21)'); 29 | cy.log('response colors') 30 | // red 31 | cy.get('.cy-api-response > .hljs > :nth-child(2)') 32 | .should('have.css', 'color', 'rgb(255, 0, 0)'); 33 | // brown 34 | cy.get('.cy-api-response > .hljs > .hljs-string') 35 | .should('have.css', 'color', 'rgb(163, 21, 21)'); 36 | // black 37 | cy.get(':nth-child(8)') 38 | .should('have.css', 'color', 'rgb(0, 0, 0)'); 39 | }) 40 | 41 | 42 | it('xml response, no color', () => { 43 | cy.api({ 44 | url: '/xml' 45 | }).then(response => { 46 | expect(response.body).to.be.deep.eq('XML') 47 | }) 48 | cy.get('xml').should('have.css', 'color', 'rgb(0, 0, 0)'); 49 | }) 50 | 51 | 52 | it('handles spaces', () => { 53 | cy.api({ 54 | url: '/json-white-space' 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // add cypress-map commands and queries 2 | import 'cypress-map' 3 | 4 | // adds cy.api command 5 | import '../../dist/support' 6 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "es5", 6 | "dom" 7 | ], 8 | "types": [ 9 | "node", 10 | "cypress" 11 | ], 12 | "typeRoots": [ 13 | "./node_modules/@types", 14 | "./dist" 15 | ] 16 | }, 17 | "include": [ 18 | "**/*.ts" 19 | ] 20 | } -------------------------------------------------------------------------------- /images/cy-api.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/cy-api/5510c910c15d8a269b309bc8aa125c2000c1ed1e/images/cy-api.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bahmutov/cy-api", 3 | "version": "0.0.0-development", 4 | "description": "Custom command cy.api", 5 | "main": "dist/index.js", 6 | "types": "dist/types.d.ts", 7 | "files": [ 8 | "/dist/*" 9 | ], 10 | "scripts": { 11 | "badges": "npx -p dependency-version-badge update-badge cypress", 12 | "test": "cypress run", 13 | "cy:open": "cypress open", 14 | "start": "node -r @bahmutov/all-logs ./server", 15 | "e2e": "start-test 3003", 16 | "dev": "start-test 3003 cy:open", 17 | "semantic-release": "semantic-release", 18 | "stop-only": "stop-only --folder cypress", 19 | "warn-only": "stop-only --warn --folder cypress", 20 | "build": "tsc", 21 | "build:watch": "tsc --watch" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/bahmutov/cy-api.git" 26 | }, 27 | "keywords": [ 28 | "cypress", 29 | "cypress-io", 30 | "cy-api" 31 | ], 32 | "author": "Gleb Bahmutov (https://glebbahmutov.com/)", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/bahmutov/cy-api/issues" 36 | }, 37 | "homepage": "https://github.com/bahmutov/cy-api#readme", 38 | "devDependencies": { 39 | "@bahmutov/all-logs": "1.8.1", 40 | "@types/node": "^17.0.18", 41 | "cypress": "13.17.0", 42 | "cypress-map": "^1.43.0", 43 | "debug": "4.4.0", 44 | "express": "4.17.1", 45 | "husky": "3.1.0", 46 | "prettier": "^2.8.1", 47 | "semantic-release": "^24.2.0", 48 | "start-server-and-test": "2.0.12", 49 | "stop-only": "3.1.0", 50 | "typescript": "4.5.4" 51 | }, 52 | "peerDependencies": { 53 | "cypress": ">=3" 54 | }, 55 | "dependencies": { 56 | "@types/common-tags": "1.8.4", 57 | "common-tags": "1.8.2", 58 | "highlight.js": "11.4.0" 59 | }, 60 | "publishConfig": { 61 | "access": "public" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "npm run warn-only", 66 | "pre-push": "npm run stop-only && npm run e2e" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "major": { 7 | "automerge": false 8 | }, 9 | "prHourlyLimit": 2, 10 | "updateNotScheduled": false, 11 | "timezone": "America/New_York", 12 | "schedule": [ 13 | "every weekend" 14 | ], 15 | "masterIssue": true, 16 | "packageRules": [ 17 | { 18 | "packagePatterns": [ 19 | "*" 20 | ], 21 | "excludePackagePatterns": [ 22 | "common-tags", 23 | "cypress", 24 | "semantic-release", 25 | "start-server-and-test", 26 | "@bahmutov/all-logs" 27 | ], 28 | "enabled": false 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /server-public/test.html: -------------------------------------------------------------------------------- 1 | MY PAGE -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // test server 2 | 3 | const verbose = require('debug')('verbose') 4 | const info = require('debug')('info') 5 | const debug = require('util').debuglog('hello') 6 | const express = require('express') 7 | const app = express() 8 | const port = 3003 9 | 10 | app.use(express.json()) 11 | 12 | if (global.messages) { 13 | require('@bahmutov/all-logs/middleware/express')(app) 14 | } 15 | 16 | app.use(express.static('server-public')) 17 | 18 | const answer = 'Hello World!' 19 | 20 | app.get('/', (req, res) => { 21 | // 3 different types of logging 22 | console.log('processing %s %s', req.method, req.path) 23 | verbose('processing /') 24 | debug('server responding with %s', answer) 25 | res.send(answer) 26 | }) 27 | 28 | app.get('/logs', (req, res) => { 29 | debug('server start request') 30 | // 3 different types of logging 31 | console.log('processing %s %s', req.method, req.path) 32 | console.info('INFO') 33 | info('info log') 34 | verbose('processing /') 35 | debug('server responding with %s', answer) 36 | verbose('processing / end') 37 | res.send(answer) 38 | console.log('finish %s %s', req.method, req.path) 39 | }) 40 | 41 | app.get('/json', (req, res) => { 42 | const answerJSON = { string: 'string', int: 1234, object: { array: [1, 2] } } 43 | res.send(answerJSON) 44 | }) 45 | 46 | app.post('/json', (req, res) => { 47 | // grab the body of the request 48 | const body = req.body 49 | console.log('POST /json') 50 | console.log(body) 51 | res.send(body) 52 | }) 53 | 54 | app.put('/json', (req, res) => { 55 | // grab the body of the request 56 | const body = req.body 57 | console.log('PUT /json') 58 | console.log(body) 59 | res.send(body) 60 | }) 61 | 62 | // https://github.com/bahmutov/cy-api/issues/156 63 | app.get('/json-white-space', (req, res) => { 64 | const answerJSON = { forwardTo: ' ' } 65 | res.send(answerJSON) 66 | }) 67 | 68 | app.get('/xml', (req, res) => { 69 | const answerXML = 'XML' 70 | res.set('Content-Type', 'text/xml') 71 | res.send(answerXML) 72 | }) 73 | 74 | app.get('/random-number', (req, res) => { 75 | const n = Math.ceil(Math.random() * 10) 76 | console.log('returning a random number %d', n) 77 | res.send({ n }) 78 | }) 79 | 80 | app.get('/sum', (req, res) => { 81 | console.log('summing', req.body) 82 | const sum = req.body.a + req.body.b 83 | console.log('returning sum %d', sum) 84 | res.send({ sum }) 85 | }) 86 | 87 | app.listen(port, () => console.log(`Example app listening on port ${port}!`)) 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as support from "./support"; 2 | export default support; -------------------------------------------------------------------------------- /src/support.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { html } from 'common-tags' 4 | import hljs from 'highlight.js' 5 | const pack = require('../package.json') 6 | 7 | // 8 | // implementation of the custom command "cy.api" 9 | // https://github.com/bahmutov/cy-api 10 | // 11 | 12 | // shortcuts to a few Lodash methods 13 | const { get, filter, map, uniq } = Cypress._ 14 | 15 | let firstApiRequest: boolean 16 | 17 | let globalDisplayRequest = true 18 | 19 | Cypress.on('test:before:run', () => { 20 | // @ts-ignore 21 | const apiDisplayRequest = Cypress.config('apiDisplayRequest') 22 | globalDisplayRequest = 23 | apiDisplayRequest === undefined ? true : (apiDisplayRequest as boolean) 24 | firstApiRequest = true 25 | // @ts-ignore 26 | const doc: Document = cy.state('document') 27 | doc.body.innerHTML = '' 28 | }) 29 | 30 | function initApiOptions(): ApiOptions { 31 | if (globalDisplayRequest === false) { 32 | return { displayRequest: false } 33 | } else { 34 | return { displayRequest: true } 35 | } 36 | } 37 | 38 | Cypress.Commands.add( 39 | 'api', 40 | (options: Partial, name = 'api') => { 41 | const apiOptions = initApiOptions() 42 | const hasApiMessages = Cypress.env('API_MESSAGES') === false ? false : true 43 | let normalizedTypes: string[] = [] 44 | let normalizedNamespaces: string[] = [] 45 | var { container, win, doc } = getContainer() 46 | const messagesEndpoint = get( 47 | Cypress.env(), 48 | 'cyApi.messages', 49 | '/__messages__', 50 | ) 51 | 52 | // first reset any messages on the server 53 | if (hasApiMessages) { 54 | cy.request({ 55 | method: 'POST', 56 | url: messagesEndpoint, 57 | log: false, 58 | failOnStatusCode: false, // maybe there is no endpoint with logs 59 | }) 60 | } 61 | 62 | // should we log the message before a request 63 | // in case it fails? 64 | Cypress.log({ 65 | name, 66 | message: options.url, 67 | consoleProps() { 68 | return { 69 | request: options, 70 | } 71 | }, 72 | }) 73 | 74 | let topMargin = '0' 75 | if (firstApiRequest) { 76 | container.innerHTML = '' 77 | } 78 | if (apiOptions.displayRequest) { 79 | if (firstApiRequest) { 80 | // remove existing content from the application frame 81 | firstApiRequest = false 82 | container.innerHTML = html` 83 | 89 | 118 | ` 119 | } else { 120 | container.innerHTML += '

\n' 121 | topMargin = '1em' 122 | } 123 | } 124 | 125 | if (apiOptions.displayRequest) { 126 | container.innerHTML += 127 | // should we use custom class and insert class style? 128 | '
\n' + 129 | `

Cy-api: ${name}

\n` + 130 | '
\n' + 131 | 'Request:\n' + 132 | '
' +
133 |         formatRequest(options) +
134 |         '\n
' 135 | } 136 | 137 | cy.request({ 138 | ...options, 139 | log: false, 140 | }) 141 | .then( 142 | ({ duration, body, status, headers, requestHeaders, statusText }) => { 143 | return printResponse( 144 | container, 145 | hasApiMessages, 146 | messagesEndpoint, 147 | normalizedTypes, 148 | normalizedNamespaces, 149 | apiOptions.displayRequest, 150 | ).then(({ messages }) => { 151 | return cy.wrap( 152 | { 153 | messages, 154 | duration, 155 | body, 156 | status, 157 | headers, 158 | requestHeaders, 159 | statusText, 160 | }, 161 | { log: false }, 162 | ) 163 | }) 164 | }, 165 | ) 166 | .then( 167 | ({ 168 | messages, 169 | duration, 170 | body, 171 | status, 172 | headers, 173 | requestHeaders, 174 | statusText, 175 | }) => { 176 | // render the response object 177 | // TODO render headers? 178 | if (apiOptions.displayRequest) { 179 | container.innerHTML += 180 | '
\n' + 181 | `Response: ${status} ${duration}ms\n` + 182 | '
' +
183 |               formatResponse(body, headers) +
184 |               '\n
' 185 | } 186 | 187 | // log the response 188 | Cypress.log({ 189 | name: 'response', 190 | message: options.url, 191 | consoleProps() { 192 | return { 193 | type: typeof body, 194 | response: body, 195 | } 196 | }, 197 | }) 198 | 199 | for (const type of normalizedTypes) { 200 | addOnClickFilter(type) 201 | } 202 | 203 | for (const namespace of normalizedNamespaces) { 204 | addOnClickFilter(namespace) 205 | } 206 | 207 | win.scrollTo(0, doc.body.scrollHeight) 208 | 209 | return { 210 | messages, 211 | // original response information 212 | duration, 213 | body, 214 | status, 215 | statusText, 216 | headers, 217 | requestHeaders, 218 | } 219 | }, 220 | ) 221 | }, 222 | ) 223 | 224 | const printResponse = ( 225 | container: HTMLElement, 226 | hasApiMessages: boolean, 227 | messagesEndpoint: string, 228 | normalizedTypes: string[], 229 | normalizedNamespaces: string[], 230 | displayRequest = true, 231 | ) => { 232 | let messages: Message[] = [] 233 | if (hasApiMessages) { 234 | return cy 235 | .request({ 236 | url: messagesEndpoint, 237 | log: false, 238 | failOnStatusCode: false, // maybe there is no endpoint with logs 239 | }) 240 | .then((res) => { 241 | messages = get(res, 'body.messages', []) 242 | if (messages.length) { 243 | const types = uniq(map(messages, 'type')).sort() 244 | // types will be like 245 | // ['console', 'debug', 'util.debuglog'] 246 | const namespaces = types.map((type) => { 247 | return { 248 | type, 249 | namespaces: uniq( 250 | map(filter(messages, { type }), 'namespace'), 251 | ).sort(), 252 | } 253 | }) 254 | // namespaces will be like 255 | // [ 256 | // {type: 'console', namespaces: ['log']}, 257 | // {type: 'util.debuglog', namespaces: ['HTTP']} 258 | // ] 259 | if (displayRequest) { 260 | container.innerHTML += 261 | '
\n' + 262 | '
\n' + 263 | `Server logs` 264 | 265 | if (types.length) { 266 | for (const type of types) { 267 | const normalizedType = normalize(type) 268 | normalizedTypes.push(normalizedType) 269 | container.innerHTML += `\n ${type}` 270 | } 271 | container.innerHTML += '
\n' 272 | } 273 | if (namespaces.length) { 274 | container.innerHTML += 275 | '\n' + 276 | namespaces 277 | .map((n) => { 278 | if (!n.namespaces.length) { 279 | return '' 280 | } 281 | return n.namespaces 282 | .map((namespace) => { 283 | const normalizedNamespace = normalize(n.type, namespace) 284 | normalizedNamespaces.push(normalizedNamespace) 285 | return `\n ${n.type}.${namespace}` 288 | }) 289 | .join('') 290 | }) 291 | .join('') + 292 | '
\n' 293 | } 294 | 295 | container.innerHTML += 296 | '\n
' +
297 |               messages
298 |                 .map((m) => {
299 |                   const s =
300 |                     typeof m.message === 'string'
301 |                       ? m.message
302 |                       : JSON.stringify(m.message)
303 |                   const html = `
${m.type} ${m.namespace}: ${s}
` 307 | return html 308 | }) 309 | .join('') + 310 | '\n
' 311 | } 312 | } 313 | }) 314 | .then(() => cy.wrap({ messages }, { log: false })) 315 | } else { 316 | return cy.wrap({ messages }, { log: false }) 317 | } 318 | } 319 | 320 | const normalize = (type: string, namespace: string | null = null): string => { 321 | let normalized = type.replace('.', '-') 322 | if (namespace) { 323 | namespace = namespace.replace('.', '-') 324 | normalized += `-${namespace}` 325 | } 326 | return normalized 327 | } 328 | 329 | const addOnClickFilter = (filterId: string): void => { 330 | // @ts-ignore 331 | const doc = cy.state('document') 332 | doc.getElementById(`check-${filterId}`).onclick = () => { 333 | const checkbox = doc.getElementById(`check-${filterId}`) 334 | const elements = doc.getElementsByClassName(checkbox.value) 335 | for (let log of elements) { 336 | log.style.display = checkbox.checked ? 'block' : 'none' 337 | } 338 | } 339 | } 340 | 341 | const getContainer = () => { 342 | // @ts-ignore 343 | const doc: Document = cy.state('document') 344 | // @ts-ignore 345 | const win: Window = cy.state('window') 346 | let container = doc.querySelector('.container') 347 | if (!container) { 348 | // clear the body of the application's iframe 349 | // in Cypress v12 350 | const innerContainer = doc.querySelector('.inner-container') 351 | if (innerContainer) { 352 | innerContainer.remove() 353 | } 354 | // and Cypress v12 styles 355 | const styles = doc.querySelector('style') 356 | if (styles) { 357 | styles.remove() 358 | } 359 | 360 | // and create our own container 361 | container = doc.createElement('div') 362 | container.className = 'container' 363 | doc.body.appendChild(container) 364 | } 365 | container.className = 'container' 366 | return { container, win, doc } 367 | } 368 | 369 | const formatJSon = (jsonObject: object) => { 370 | return hljs.highlight(JSON.stringify(jsonObject, null, 4), { 371 | language: 'json', 372 | }).value 373 | } 374 | 375 | const formatRequest = (options: Partial) => { 376 | const showCredentials = Cypress.env('API_SHOW_CREDENTIALS') 377 | const auth = options?.auth as { 378 | username?: string 379 | password?: string 380 | bearer?: string 381 | } 382 | const hasPassword = auth?.password 383 | const hasBearer = auth?.bearer 384 | 385 | if (!showCredentials && hasPassword && hasBearer) { 386 | return formatJSon({ 387 | ...options, 388 | auth: { 389 | ...options.auth, 390 | bearer: '*****', 391 | password: '*****', 392 | }, 393 | }) 394 | } else if (!showCredentials && hasPassword) { 395 | return formatJSon({ 396 | ...options, 397 | auth: { 398 | ...options.auth, 399 | password: '*****', 400 | }, 401 | }) 402 | } else if (!showCredentials && hasBearer) { 403 | return formatJSon({ 404 | ...options, 405 | auth: { 406 | ...options.auth, 407 | bearer: '*****', 408 | }, 409 | }) 410 | } 411 | return formatJSon(options) 412 | } 413 | 414 | const formatResponse = ( 415 | body: object, 416 | headers: { [key: string]: string | string[] }, 417 | ) => { 418 | if (headers?.['content-type']?.includes('application/json')) { 419 | return formatJSon(body) 420 | } else { 421 | return body 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | interface CyApiOptions { 2 | api: ApiOptions 3 | } 4 | 5 | interface ApiOptions { 6 | displayRequest: boolean 7 | } 8 | 9 | interface Message { 10 | type: string 11 | namespace: string 12 | message: string 13 | } 14 | 15 | interface Messages { 16 | messages: Message[] 17 | } 18 | 19 | declare namespace Cypress { 20 | 21 | interface TestConfigOverrides { 22 | /** 23 | * Display cy.api result 24 | */ 25 | apiDisplayRequest?: boolean 26 | } 27 | 28 | interface Chainable { 29 | /** 30 | * Custom command to execute HTTP request to the server 31 | * and display the results in the application under test iframe. 32 | * 33 | * @example 34 | ``` 35 | cy.api({ url: '/' }, 'my hello world') 36 | .then(response => { 37 | expect(response).to.include.keys([ 38 | 'status', 39 | 'statusText', 40 | 'body', 41 | 'requestHeaders', 42 | 'headers', 43 | 'duration' 44 | ]) 45 | expect(response.body).to.equal('Hello World!') 46 | }) 47 | ``` 48 | */ 49 | api (options: Partial, name?: string): Chainable & Messages> 50 | } 51 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "declaration": true, 5 | "allowJs": true, 6 | "target": "es2021", 7 | "strict": true, 8 | "types": ["cypress", "node"], 9 | "moduleResolution": "node", 10 | "skipLibCheck": true 11 | }, 12 | "include": ["./src/**/*"], 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | --------------------------------------------------------------------------------