├── .github └── workflows │ ├── badges.yml │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ ├── click.cy.js │ ├── page-enable.cy.js │ ├── rendered-font.cy.js │ ├── spec.cy.js │ └── spec1.cy.js └── tsconfig.json ├── package-lock.json ├── package.json ├── public ├── app.js ├── index.html └── style.css ├── renovate.json └── src ├── index.d.ts └── index.js /.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 5 * * *' 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 | test: 5 | runs-on: ubuntu-24.04 6 | steps: 7 | - name: Checkout 🛎 8 | uses: actions/checkout@v4 9 | 10 | - name: Run tests 🧪 11 | # https://github.com/cypress-io/github-action 12 | uses: cypress-io/github-action@v6 13 | with: 14 | build: npm run lint 15 | 16 | - name: Semantic Release 🚀 17 | uses: cycjimmy/semantic-release-action@v4 18 | with: 19 | branch: main 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cypress/downloads/ 3 | cypress/screenshots/ 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.2 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-cdp [![ci](https://github.com/bahmutov/cypress-cdp/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/bahmutov/cypress-cdp/actions/workflows/ci.yml) ![cypress version](https://img.shields.io/badge/cypress-13.17.0-brightgreen) 2 | 3 | > A custom Cypress command to wrap the [Chrome remote debugger protocol](https://chromedevtools.github.io/devtools-protocol/) low level command 4 | 5 | Read my blog posts that show this plugin in action: 6 | 7 | - [Cypress automation](https://glebbahmutov.com/blog/cypress-automation/) 8 | - [When Can The Test Click](https://glebbahmutov.com/blog/when-can-the-test-click/) 9 | - [Solve The First Click](https://glebbahmutov.com/blog/solve-the-first-click/) 10 | - [Rendered font](https://glebbahmutov.com/blog/rendered-font/) 11 | - [Emulate Media In Cypress Tests](https://glebbahmutov.com/blog/cypress-emulate-media/) 12 | - [Testing CSS Print Media Styles](https://glebbahmutov.com/blog/test-print-styles/) 13 | 14 | 🎓 Covered in my course [Cypress Plugins](https://cypress.tips/courses/cypress-plugins), [Cypress Network Testing Exercises](https://cypress.tips/courses/network-testing), and [TDD Calculator](https://cypress.tips/courses/tdd-calculator): 15 | - [Lesson e5: Print page as PDF](https://cypress.tips/courses/tdd-calculator/lessons/e5) 16 | - [Lesson c6: Exit fullscreen mode using cypress-cdp](https://cypress.tips/courses/cypress-plugins/lessons/c6) 17 | - [Lesson c7: Set custom timezone and locale](https://cypress.tips/courses/cypress-plugins/lessons/c7) 18 | - [Lesson c9: Disable network caching during the test](https://cypress.tips/courses/cypress-plugins/lessons/c9) 19 | - [Bonus 99: Confirm the form works with CSS styles disabled](https://cypress.tips/courses/network-testing/lessons/bonus99) 20 | 21 | ## Install 22 | 23 | Add this plugin as your dev dependency 24 | 25 | ``` 26 | # install using NPM 27 | $ npm i -D cypress-cdp 28 | # or install using Yarn 29 | $ yarn add -D cypress-cdp 30 | ``` 31 | 32 | Then import this plugin in your spec or support file 33 | 34 | ```js 35 | // https://github.com/bahmutov/cypress-cdp 36 | import 'cypress-cdp' 37 | ``` 38 | 39 | ## Examples 40 | 41 | ### Disable caching 42 | 43 | Similar to clicking the checkbox "Disable cache" in the Network tab 44 | 45 | ```js 46 | import 'cypress-cdp' 47 | cy.CDP('Network.setCacheDisabled', { 48 | cacheDisabled: true, 49 | }) 50 | ``` 51 | 52 | ## API 53 | 54 | ### CDP 55 | 56 | #### CDP example 1 57 | 58 | ```js 59 | const selector = 'button#one' 60 | cy.CDP('Runtime.evaluate', { 61 | expression: 'frames[0].document.querySelector("' + selector + '")', 62 | }).should((v) => { 63 | expect(v.result).to.have.property('objectId') 64 | }) 65 | ``` 66 | 67 | **Tip:** be careful with selectors, you probably will need to escape them. For example, this library uses this escape 68 | 69 | ```js 70 | const escapedSelector = JSON.stringify(selector) 71 | cy.CDP('Runtime.evaluate', { 72 | expression: 'frames[0].document.querySelector(' + escapedSelector + ')', 73 | }) 74 | ``` 75 | 76 | ### hasEventListeners 77 | 78 | #### hasEventListeners example 79 | 80 | ```js 81 | // any event listeners are attached 82 | cy.hasEventListeners('button#one') 83 | // "click" event listeners are attached 84 | cy.hasEventListeners('button#one', { type: 'click' }) 85 | ``` 86 | 87 | ### getCDPNodeId 88 | 89 | Returns the internal element's Node Id that can be used to query the run-time properties, for example the rendered font. 90 | 91 | ```js 92 | cy.getCDPNodeId('body').then((nodeId) => { 93 | cy.CDP('CSS.getPlatformFontsForNode', { 94 | nodeId, 95 | }) 96 | }) 97 | ``` 98 | 99 | ## Type definitions 100 | 101 | Defined in [src/index.d.ts](./src/index.d.ts) and copied from the original [PR 66237](https://github.com/DefinitelyTyped/DefinitelyTyped/pull/66237). If you want your project to "know" the `cy.CDP` command, include this dependency, for example: 102 | 103 | ```json 104 | { 105 | "types": ["cypress", "cypress-cdp"] 106 | } 107 | ``` 108 | 109 | ## Small print 110 | 111 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2022 112 | 113 | - [@bahmutov](https://twitter.com/bahmutov) 114 | - [glebbahmutov.com](https://glebbahmutov.com) 115 | - [blog](https://glebbahmutov.com/blog) 116 | - [videos](https://www.youtube.com/glebbahmutov) 117 | - [presentations](https://slides.com/bahmutov) 118 | - [cypress.tips](https://cypress.tips) 119 | - [Cypress Tips & Tricks Newsletter](https://cypresstips.substack.com/) 120 | - [my Cypress courses](https://cypress.tips/courses) 121 | 122 | License: MIT - do anything with the code, but don't blame me if it does not work. 123 | 124 | Support: if you find any problems with this module, email / tweet / 125 | [open issue](https://github.com/bahmutov/cypress-cdp/issues) on Github 126 | 127 | ## MIT License 128 | 129 | Copyright (c) 2022 Gleb Bahmutov <gleb.bahmutov@gmail.com> 130 | 131 | Permission is hereby granted, free of charge, to any person 132 | obtaining a copy of this software and associated documentation 133 | files (the "Software"), to deal in the Software without 134 | restriction, including without limitation the rights to use, 135 | copy, modify, merge, publish, distribute, sublicense, and/or sell 136 | copies of the Software, and to permit persons to whom the 137 | Software is furnished to do so, subject to the following 138 | conditions: 139 | 140 | The above copyright notice and this permission notice shall be 141 | included in all copies or substantial portions of the Software. 142 | 143 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 144 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 145 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 146 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 147 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 148 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 149 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 150 | OTHER DEALINGS IN THE SOFTWARE. 151 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | fixturesFolder: false, 5 | viewportWidth: 400, 6 | viewportHeight: 200, 7 | e2e: { 8 | setupNodeEvents(on, config) {}, 9 | supportFile: false, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/e2e/click.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../src' 5 | 6 | it('clicks on the button too soon', () => { 7 | cy.visit('public/index.html') 8 | cy.get('button#one').click() 9 | // we clicked too soon - the button has no event handler yet 10 | cy.get('#output').should('have.text', '').and('not.be.visible') 11 | }) 12 | 13 | it('clicks on the button after a delay', () => { 14 | cy.visit('public/index.html') 15 | cy.wait(1000) 16 | cy.get('button#one').click() 17 | cy.contains('#output', 'clicked') 18 | }) 19 | 20 | it('clicks on the button when there is an event handler', () => { 21 | cy.visit('public/index.html') 22 | 23 | const selector = 'button#one' 24 | cy.CDP('Runtime.evaluate', { 25 | expression: 'frames[0].document.querySelector("' + selector + '")', 26 | }) 27 | .should((v) => { 28 | expect(v.result).to.have.property('objectId') 29 | }) 30 | .its('result.objectId') 31 | .should('be.a', 'string') 32 | .then((objectId) => { 33 | cy.CDP('DOMDebugger.getEventListeners', { 34 | objectId, 35 | depth: -1, 36 | pierce: true, 37 | }).should((v) => { 38 | expect(v.listeners).to.have.length.greaterThan(0) 39 | }) 40 | }) 41 | // now we can click that button 42 | cy.get(selector).click() 43 | }) 44 | 45 | it('uses hasEventListeners command', () => { 46 | cy.visit('public/index.html') 47 | cy.hasEventListeners('button#one') 48 | // now we can click that button 49 | cy.get('button#one').click() 50 | }) 51 | 52 | it('uses hasEventListeners command with timeout option', () => { 53 | cy.visit('public/index.html') 54 | cy.hasEventListeners('button#two', { timeout: 10000 }) 55 | // now we can click that button 56 | cy.get('button#two').click() 57 | cy.get('output#output-two').should('contain.text', 'clicked') 58 | }) 59 | 60 | it('hasEventListeners command handles re-renders', () => { 61 | cy.visit('public/index.html') 62 | cy.hasEventListeners('button#three') 63 | // now we can click that button 64 | cy.get('button#three').click() 65 | cy.get('output#output-three').should('contain.text', 'clicked') 66 | }) 67 | 68 | it('handles selectors with quotes', () => { 69 | cy.visit('public/index.html') 70 | const selector = '[aria-label="Click this button"]' 71 | cy.hasEventListeners(selector) 72 | // now we can click that button 73 | cy.get(selector).click() 74 | }) 75 | 76 | it('handles jQuery selectors', () => { 77 | cy.visit('public/index.html') 78 | const selector = 'button:contains("Click me!")' 79 | cy.hasEventListeners(selector) 80 | // now we can click that button 81 | cy.get(selector).click() 82 | }) 83 | -------------------------------------------------------------------------------- /cypress/e2e/page-enable.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../src' 5 | 6 | it('enables the Page domain', () => { 7 | cy.CDP('Page.enable') 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/e2e/rendered-font.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import '../../src' 4 | 5 | it('gets the declared font', () => { 6 | cy.visit('/public/index.html') 7 | cy.get('body').then(($body) => { 8 | cy.window() 9 | .invoke('getComputedStyle', $body[0]) 10 | .its('fontFamily') 11 | .should('include', 'Satisfy') 12 | }) 13 | }) 14 | 15 | it('gets the rendered font', () => { 16 | cy.visit('/public/index.html') 17 | cy.getCDPNodeId('body').then((nodeId) => { 18 | console.log(nodeId) 19 | cy.CDP('CSS.getPlatformFontsForNode', { 20 | nodeId, 21 | }) 22 | .its('fonts') 23 | .should('have.length.greaterThan', 0) 24 | .its('0.familyName') 25 | .then(cy.log) 26 | }) 27 | }) 28 | 29 | it.skip('gets the rendered font (internal)', () => { 30 | cy.visit('/public/index.html') 31 | cy.get('body').then(($body) => { 32 | cy.window() 33 | .invoke('getComputedStyle', $body[0]) 34 | .its('fontFamily') 35 | .should('include', 'Satisfy') 36 | }) 37 | 38 | cy.wait(1000) 39 | cy.CDP( 40 | 'Runtime.evaluate', 41 | { 42 | expression: 'frames[0].document.querySelector("body p")', 43 | }, 44 | { log: false }, 45 | ) 46 | .should((v) => { 47 | if (!v || !v.result || !v.result.objectId) { 48 | throw new Error(`Cannot find element "body"`) 49 | } 50 | }) 51 | .then((v) => { 52 | console.log(v) 53 | // iframe inspection w/o getFlattenedDocument() 54 | // https://gist.github.com/imaman/cd7c943e0831a447b1d2b073ede347e2 55 | const nodeId = v.result.objectId 56 | 57 | // https://stackoverflow.com/questions/47911613/fetch-rendered-font-using-chrome-headless-browser 58 | // cy.CDP('Page.getFrameTree') 59 | // .then(console.log) 60 | // .its('frameTree.childFrames.0') 61 | // .then(console.log) 62 | // cy.CDP('DOM.enable') 63 | cy.CDP('CSS.enable') 64 | cy.CDP('DOM.getDocument', { 65 | depth: 50, 66 | pierce: true, 67 | }).then( 68 | // .then( 69 | // console.log, 70 | // ) 71 | (doc) => { 72 | console.log('doc', doc) 73 | // const appDoc = doc.root.children[0].nodeId 74 | // let's get the application iframe 75 | cy.CDP('DOM.querySelector', { 76 | nodeId: doc.root.nodeId, 77 | selector: 'iframe.aut-iframe', 78 | }).then((iframeQueryResult) => { 79 | console.log('iframeQueryResult', iframeQueryResult) 80 | 81 | cy.CDP('DOM.describeNode', { 82 | nodeId: iframeQueryResult.nodeId, 83 | }).then((iframeDescription) => { 84 | console.log('iframeDescription', iframeDescription) 85 | 86 | cy.CDP('DOM.resolveNode', { 87 | backendNodeId: 88 | iframeDescription.node.contentDocument.backendNodeId, 89 | }).then((contentDocRemoteObject) => { 90 | console.log('contentDocRemoteObject', contentDocRemoteObject) 91 | 92 | cy.CDP('DOM.requestNode', { 93 | objectId: contentDocRemoteObject.object.objectId, 94 | }).then((contentDocNode) => { 95 | console.log('contentDocNode', contentDocNode) 96 | 97 | cy.CDP('DOM.querySelector', { 98 | nodeId: contentDocNode.nodeId, 99 | selector: 'body', 100 | }).then((body) => { 101 | console.log('app body', body) 102 | cy.CDP('CSS.getPlatformFontsForNode', { 103 | nodeId: body.nodeId, 104 | }) 105 | .then(console.log) 106 | .its('fonts') 107 | .should('have.length.gt', 0) 108 | .then((fonts) => 109 | Cypress._.find(fonts, { familyName: 'Satisfy' }), 110 | ) 111 | .should('exist') 112 | }) 113 | }) 114 | }) 115 | }) 116 | }) 117 | }, 118 | ) 119 | 120 | // https://chromedevtools.github.io/devtools-protocol/tot/CSS/ 121 | // https://chromiumcodereview.appspot.com/22923010/patch/47001/48013 122 | // debugger 123 | // cy.CDP('CSS.getPlatformFontsForNode', { 124 | // name: 'nodeId', 125 | // $ref: nodeId, 126 | // }).then(cy.log) 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /cypress/e2e/spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | import '../../src' 5 | 6 | it('has the CDP command', () => { 7 | expect(cy.CDP, 'CDP method').to.be.a('function') 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/e2e/spec1.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 4 | it('clicks on the button', () => { 5 | cy.visit('public/index.html') 6 | // let the application fully load 7 | cy.wait(5000) 8 | cy.get('button#one').click() 9 | cy.contains('#output', 'clicked') 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "module": "commonjs" /* Specify what module code is generated. */, 5 | "types": [ 6 | "cypress" 7 | ] /* Specify type package names to be included without being referenced in a source file. */, 8 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 9 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "skipLibCheck": true, 12 | "allowJs": true, 13 | "noEmit": true, 14 | "lib": [ 15 | "es2023", 16 | "dom" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 18 | }, 19 | "include": ["./e2e/**/*.js"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-cdp", 3 | "version": "0.0.0-development", 4 | "description": "A custom Cypress command to wrap the remote debugger protocol low level command", 5 | "main": "src", 6 | "files": [ 7 | "src" 8 | ], 9 | "types": "src/index.d.ts", 10 | "scripts": { 11 | "test": "cypress run", 12 | "semantic-release": "semantic-release", 13 | "badges": "npx -p dependency-version-badge update-badge cypress", 14 | "lint": "tsc --project ./cypress/tsconfig.json --noEmit" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/bahmutov/cypress-cdp.git" 19 | }, 20 | "keywords": [ 21 | "cypress-plugin" 22 | ], 23 | "author": "Gleb Bahmutov ", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/bahmutov/cypress-cdp/issues" 27 | }, 28 | "homepage": "https://github.com/bahmutov/cypress-cdp#readme", 29 | "devDependencies": { 30 | "cypress": "13.17.0", 31 | "prettier": "3.5.3", 32 | "semantic-release": "24.2.5", 33 | "typescript": "^5.6.3" 34 | }, 35 | "dependencies": { 36 | "devtools-protocol": "^0.0.1468520" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | const btn_one = document.querySelector('#one') 2 | const btn_two = document.querySelector('#two') 3 | const btn_three = document.querySelector('#three') 4 | const output_one = document.querySelector('#output') 5 | const output_two = document.querySelector('#output-two') 6 | const output_three = document.querySelector('#output-three') 7 | 8 | // add event listeners after a short delay 9 | setTimeout(() => { 10 | btn_one.addEventListener('click', () => { 11 | output.innerText = 'clicked' 12 | }) 13 | }, 1000) 14 | 15 | // add event listeners after a long delay 16 | setTimeout(() => { 17 | btn_two.addEventListener('click', () => { 18 | output_two.innerText = 'clicked' 19 | }) 20 | }, 5000) 21 | 22 | setTimeout(() => { 23 | let new_button = document.createElement('button') 24 | new_button.id = 'three' 25 | new_button.innerText = 'Click me three!' 26 | new_button.addEventListener('click', () => { 27 | output_three.innerText = 'clicked' 28 | }) 29 | btn_three.replaceWith(new_button) 30 | }, 1000) 31 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 |

This is the text

11 |
12 | 13 | 14 |
15 |
16 | 19 | 20 |
21 |
22 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Satisfy', cursive; 3 | div:not(:last-child) { 4 | margin-bottom: 1rem; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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": ["after 10pm and before 5am on every weekday", "every weekend"], 13 | "masterIssue": true, 14 | "labels": ["type: dependencies", "renovate"] 15 | } 16 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for cypress-cdp 1.3 2 | // Project: https://github.com/bahmutov/cypress-cdp#readme 3 | // Definitions by: Anthony Froissant 4 | // Louis Loiseau-Billon 5 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 6 | // copied from https://github.com/DefinitelyTyped/DefinitelyTyped/pull/66237 7 | 8 | /// 9 | 10 | import type Protocol from 'devtools-protocol/types/protocol' 11 | import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping' 12 | 13 | declare global { 14 | // Augment the Cypress namespace to include type definitions for cypress-cdp. 15 | namespace Cypress { 16 | namespace CDP { 17 | //#region CDP 18 | type RdpCommands = ProtocolMapping.Commands 19 | type RdpCommandNames = keyof RdpCommands 20 | 21 | type CdpCommandFnParams = 22 | RdpCommands[RdpCommandName]['paramsType'][number] extends never 23 | ? Record 24 | : RdpCommands[RdpCommandName]['paramsType'][number] 25 | type CdpCommandFnReturnType = 26 | RdpCommands[RdpCommandName]['returnType'] 27 | interface CdpCommandFnOptions { 28 | log: LogConfig 29 | } 30 | type CdpCommandFn = ( 31 | rdpCommand: RdpCommandName, 32 | params?: CdpCommandFnParams, 33 | options?: CdpCommandFnOptions, 34 | ) => Chainable> 35 | //#endregion 36 | 37 | //#region getCDPNodeId 38 | type getCDPNodeIdFn = (selector: string) => Chainable 39 | //#endregion 40 | 41 | //#region hasEventListeners 42 | interface hasEventListenersFnOptions { 43 | log?: LogConfig 44 | timeout?: number 45 | type?: string 46 | } 47 | type hasEventListenersFn = ( 48 | selector: string, 49 | options?: hasEventListenersFnOptions, 50 | ) => Chainable 51 | //#endregion 52 | } 53 | 54 | interface Chainable { 55 | /** 56 | * Custom command to send a RDP command to Chrome DevTools. 57 | * @example 58 | * const selector = 'button#one' 59 | * cy.CDP('Runtime.evaluate', { 60 | * expression: 'frames[0].document.querySelector("' + selector + '")', 61 | * }).should((v) => { 62 | * expect(v.result).to.have.property('objectId') 63 | * }) 64 | */ 65 | CDP: CDP.CdpCommandFn 66 | 67 | /** 68 | * Custom command to return the internal element's Node Id that can be used to query the run-time properties, for example the rendered font. 69 | * @example 70 | * cy.getCDPNodeId('body').then((nodeId) => { 71 | * cy.CDP('CSS.getPlatformFontsForNode', { 72 | * nodeId 73 | * }); 74 | * }); 75 | */ 76 | getCDPNodeId: CDP.getCDPNodeIdFn 77 | 78 | /** 79 | * Custom command to get listeners attached to a DOM element. 80 | * @example 81 | * cy.hasEventListeners('button#one') 82 | * @example 83 | * cy.hasEventListeners('button#one', { type: 'click' }) 84 | */ 85 | hasEventListeners: CDP.hasEventListenersFn 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | Cypress.Commands.add('CDP', (rdpCommand, params, options = {}) => { 4 | const logOptions = { 5 | name: 'CDP', 6 | message: rdpCommand, 7 | } 8 | 9 | if (rdpCommand === 'DOM.querySelector') { 10 | logOptions.message += ' ' + params.selector 11 | } else { 12 | const limitN = 60 13 | const paramsStringified = params ? JSON.stringify(params) : '' 14 | logOptions.message += 15 | paramsStringified.length > limitN 16 | ? ' ' + paramsStringified.slice(0, limitN) + '...' 17 | : ' ' + paramsStringified 18 | } 19 | 20 | let log 21 | if (options.log !== false) { 22 | log = Cypress.log(logOptions) 23 | } 24 | 25 | const getValue = () => { 26 | return Cypress.automation('remote:debugger:protocol', { 27 | command: rdpCommand, 28 | params, 29 | }) 30 | } 31 | 32 | const resolveValue = () => { 33 | return Cypress.Promise.try(getValue).then((value) => { 34 | return cy.verifyUpcomingAssertions(value, options, { 35 | onRetry: resolveValue, 36 | }) 37 | }) 38 | } 39 | 40 | return resolveValue().then((value) => { 41 | if (options.log !== false) { 42 | logOptions.consoleProps = () => { 43 | return { 44 | result: value, 45 | } 46 | } 47 | log.snapshot().end() 48 | } 49 | 50 | return value 51 | }) 52 | }) 53 | 54 | let eventListenerStatus // track most recent failure mode 55 | let eventListenerRun 56 | let maxTimeout 57 | Cypress.Commands.add('hasEventListeners', (selector, options = {}) => { 58 | let retryOpts 59 | if (options.timeout === 0) { 60 | // is a retry 61 | retryOpts = options 62 | } else { 63 | // set up the timer 64 | eventListenerStatus = '' 65 | let runId = Date.now() 66 | eventListenerRun = runId // prevent failing the next test 67 | maxTimeout = options.timeout ?? Cypress.config().defaultCommandTimeout 68 | retryOpts = { ...options, timeout: 0, log: false } 69 | setTimeout(() => { 70 | if (eventListenerRun === runId && eventListenerStatus !== 'Passed') { 71 | throw new Error(eventListenerStatus) 72 | } 73 | }, maxTimeout) 74 | } 75 | const logOptions = { 76 | name: 'hasEventListeners', 77 | message: `checking element "${selector}"`, 78 | } 79 | let log 80 | if (options.log !== false) { 81 | log = Cypress.log(logOptions) 82 | } 83 | 84 | cy.get(selector, { log: false, timeout: maxTimeout }).should(($el) => { 85 | if ($el.length !== 1) { 86 | throw new Error(`Need a single element with selector "${selector}`) 87 | } 88 | }) 89 | 90 | const escapedSelector = JSON.stringify(selector) 91 | cy.CDP( 92 | 'Runtime.evaluate', 93 | { 94 | expression: 'Cypress.$(' + escapedSelector + ')[0]', 95 | }, 96 | { log: false, timeout: maxTimeout }, 97 | ) 98 | .should((v) => { 99 | if (!v || !v.result || !v.result.objectId) { 100 | throw new Error(`Cannot find element "${selector}"`) 101 | } 102 | }) 103 | .then((v) => { 104 | const objectId = v.result.objectId 105 | cy.CDP( 106 | 'DOMDebugger.getEventListeners', 107 | { 108 | objectId, 109 | depth: -1, 110 | pierce: true, 111 | }, 112 | { 113 | log: false, 114 | }, 115 | ).then((v) => { 116 | if (!v.listeners) { 117 | eventListenerStatus = 'No listeners' 118 | cy.hasEventListeners(selector, retryOpts) 119 | return 120 | } 121 | if (!v.listeners.length) { 122 | eventListenerStatus = 'Zero listeners' 123 | cy.hasEventListeners(selector, retryOpts) 124 | return 125 | } 126 | if (options.type) { 127 | const filtered = v.listeners.filter((l) => l.type === options.type) 128 | if (!filtered.length) { 129 | eventListenerStatus = `Zero listeners of type "${options.type}"` 130 | cy.hasEventListeners(selector, retryOpts) 131 | return 132 | } 133 | } 134 | eventListenerStatus = 'Passed' 135 | if (options.log !== false) { 136 | logOptions.consoleProps = () => { 137 | return { 138 | result: v.listeners, 139 | } 140 | } 141 | } 142 | }) 143 | }) 144 | }) 145 | 146 | Cypress.Commands.add('getCDPNodeId', (selector) => { 147 | cy.CDP('DOM.enable') 148 | cy.CDP('CSS.enable') 149 | cy.CDP('DOM.getDocument', { 150 | depth: 50, 151 | pierce: true, 152 | }).then((doc) => { 153 | // let's get the application iframe 154 | cy.CDP('DOM.querySelector', { 155 | nodeId: doc.root.nodeId, 156 | selector: 'iframe.aut-iframe', 157 | }).then((iframeQueryResult) => { 158 | cy.CDP('DOM.describeNode', { 159 | nodeId: iframeQueryResult.nodeId, 160 | }).then((iframeDescription) => { 161 | cy.CDP('DOM.resolveNode', { 162 | backendNodeId: iframeDescription.node.contentDocument.backendNodeId, 163 | }).then((contentDocRemoteObject) => { 164 | cy.CDP('DOM.requestNode', { 165 | objectId: contentDocRemoteObject.object.objectId, 166 | }).then((contentDocNode) => { 167 | cy.CDP('DOM.querySelector', { 168 | nodeId: contentDocNode.nodeId, 169 | selector, 170 | }).its('nodeId') 171 | }) 172 | }) 173 | }) 174 | }) 175 | }) 176 | }) 177 | --------------------------------------------------------------------------------