├── .changeset ├── README.md └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── 1.bug.yaml │ └── 1.feature.yaml └── workflows │ ├── deploy.yml │ ├── example.yml │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── CONTRIBUTE.md ├── LICENSE.md ├── README.md ├── apps └── web │ ├── .eslintrc.js │ ├── .gitignore │ ├── CHANGELOG.md │ ├── cypress.config.js │ ├── cypress │ ├── e2e │ │ ├── clear.spec.js │ │ ├── completed.spec.js │ │ ├── counter.spec.js │ │ ├── edit.spec.js │ │ ├── item.spec.js │ │ ├── labels.spec.js │ │ ├── local.spec.js │ │ ├── new.spec.js │ │ ├── persistance.spec.js │ │ ├── routing.spec.js │ │ ├── smoke.spec.js │ │ └── todo.no-items.spec.js │ └── support │ │ ├── commands.ts │ │ ├── component-index.html │ │ ├── component.ts │ │ └── e2e.ts │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── cypress-debugger-icon.svg │ └── vite.svg │ ├── src │ ├── App.tsx │ ├── components │ │ ├── Console.tsx │ │ ├── CyEventItem.tsx │ │ ├── CyEvents.tsx │ │ ├── DarkModeToggle.tsx │ │ ├── EventDetails.tsx │ │ ├── FileUpload.tsx │ │ ├── JsonFileUpload.tsx │ │ ├── Layout.tsx │ │ ├── Network.tsx │ │ ├── PayloadHandler.tsx │ │ ├── Player.tsx │ │ └── ui │ │ │ ├── Accordion.tsx │ │ │ ├── Button.tsx │ │ │ ├── Collapsible.tsx │ │ │ ├── Tabs.tsx │ │ │ ├── Toast.tsx │ │ │ ├── Toaster.tsx │ │ │ ├── Toggle.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── useDarkMode.ts │ │ │ └── useToast.ts │ ├── context │ │ ├── cypressEvents.tsx │ │ ├── httpArchiveEntries.tsx │ │ ├── playback.tsx │ │ └── replayer.tsx │ ├── hooks │ │ ├── useLocalStorage.ts │ │ ├── usePayloadFetcher.ts │ │ └── useQuery.ts │ ├── index.css │ ├── index.tsx │ ├── lib │ │ ├── testState.ts │ │ └── utils.ts │ ├── utils │ │ ├── formatMillis.ts │ │ ├── getUrProperties.ts │ │ ├── isValidDate.ts │ │ └── isValidUrl.ts │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── packages ├── cypress-debugger │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── publish.js │ └── tsconfig.json ├── eslint-config-custom │ ├── eslint-node.js │ ├── eslint-react.js │ └── package.json ├── plugin │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── publish.js │ ├── src │ │ ├── __tests__ │ │ │ └── lib.spec.ts │ │ ├── browserLogs.ts │ │ ├── constants.ts │ │ ├── fs.ts │ │ ├── index.ts │ │ ├── install.ts │ │ ├── lib.ts │ │ └── types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── support │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── package.json │ ├── publish.js │ ├── src │ │ ├── @types │ │ │ └── index.d.ts │ │ ├── cy │ │ │ ├── cy.ts │ │ │ ├── globalHandlers.ts │ │ │ ├── index.ts │ │ │ ├── runContext.ts │ │ │ └── testHandlers.ts │ │ ├── env │ │ │ └── perf.ts │ │ ├── events │ │ │ ├── container.ts │ │ │ ├── enhancer.ts │ │ │ ├── event.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── rr │ │ │ ├── index.ts │ │ │ └── releases │ │ │ │ └── 2.0.0-alpha.4.js.src │ │ └── uuid.ts │ ├── tsconfig.json │ └── tsup.config.ts └── tsconfig │ ├── README.md │ ├── base.json │ └── package.json └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create an issue report 3 | labels: bug 4 | 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: | 9 | Before opening, please confirm: 10 | options: 11 | - label: I have searched for [duplicate or closed issues](https://github.com/currents-dev/cypress-debugger/issues) and [discussions](https://github.com/currents-dev/cypress-debugger/discussions). 12 | required: true 13 | - label: I have done my best to include a minimal, self-contained set of instructions for consistently reproducing the issue. 14 | required: true 15 | - label: I acknowledge that I will attach a **full debug log**, otherwise the issue will be closed with no response. 16 | required: true 17 | 18 | - type: markdown 19 | attributes: 20 | value: | 21 | ## Environment 22 | - type: textarea 23 | attributes: 24 | label: Environment information 25 | description: | 26 | Collect environemnt information: 27 | - Cypress version 28 | - Node.js version 29 | - Operating system 30 | - Browser version 31 | - Dependencies versions 32 | 33 | As a shortcut, run the following command inside your project and copy/paste the output below. 34 | 35 | **👉🏻 Run the command in the right environment 👈🏻**, e.g. if the problem is in CI environment, run it in the CI environment. 36 | 37 | ``` 38 | npx envinfo --system --binaries --browsers --npmPackages --duplicates --npmGlobalPackages 39 | ``` 40 | value: | 41 |
42 | 43 | ``` 44 | # Put output below this line 45 | 46 | ``` 47 | 48 |
49 | validations: 50 | required: true 51 | - type: markdown 52 | attributes: 53 | value: | 54 | ## Details 55 | - type: textarea 56 | attributes: 57 | label: Describe the bug 58 | description: A clear and concise description of what the bug is. 59 | validations: 60 | required: true 61 | 62 | - type: textarea 63 | attributes: 64 | label: Expected behavior 65 | description: A clear and concise description of what you expected to happen. 66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | attributes: 71 | label: Command and Setup 72 | description: | 73 | - The exact command you're using, including all flags and arguments -e.g. `LECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9226 npx cypress run --browser electron` 74 | - The relevant configuration files `cypress.config.j|ts|json`, `setupNodeEvents` and `cypress/plugins/index.js` if exusts 75 | - ⚠️ Remove all sensitive data from the command and configuration files ⚠️ 76 | value: | 77 |
78 | 79 | ### Command (share the exact `cypress` or `cypress-cloud` command you're running) 80 | 81 | ``` 82 | # Put output below this line 83 | 84 | ``` 85 | 86 | 87 | ### Setup files cypress.config.j|ts|json 88 | 89 | ``` 90 | # Put output below this line 91 | 92 | ``` 93 |
94 | validations: 95 | required: true 96 | 97 | - type: textarea 98 | attributes: 99 | label: Full log and debug output 100 | description: | 101 | Run in debug mode to provide more info - error messages and stack traces. 102 | 103 | - **👉🏻 Include the full log 👈🏻 - starting from running the command till receiving an error.** 104 | - Attach a link / file for long outputs. 105 | 106 | Example: 107 | 108 | - Linux: `NODE_DEBUG=cypress-har-generator* DEBUG=cypress:*,cypress-debugger* ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9226 npx cypress run --browser electron` 109 | - Windows: `cmd /V /C "set DEBUG=currents:*,cypress:*&& set NODE_DEBUG=cypress-har-generator*&& set ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9226&& npx cypress run --browser electron"` 110 | 111 | **Be sure to remove any sensitive data.** 112 | 113 | value: | 114 |
115 | 116 | ``` 117 | # Put your logs below this line 118 | 119 | 120 | ``` 121 | 122 |
123 | validations: 124 | required: true 125 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a new feature 3 | labels: enhancement 4 | 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Description 9 | description: | 10 | Please describe the feature in details 11 | 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: Example usage 18 | description: Please provide an example of how the feature will be used - code or command line 19 | validations: 20 | required: true 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | if: "!contains(toJSON(github.event.commits.*.message), '[skip ci]')" 12 | runs-on: ubuntu-latest 13 | environment: Production 14 | 15 | steps: 16 | - name: Checkout source code 17 | uses: actions/checkout@v3 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Build 23 | run: npm run build 24 | 25 | - name: Configure AWS Credentials 26 | uses: aws-actions/configure-aws-credentials@v2 27 | with: 28 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 29 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 30 | aws-region: ${{ secrets.AWS_REGION }} 31 | 32 | - name: Deploy dashboard to bucket 33 | run: aws s3 sync ./apps/web/dist s3://${{ secrets.S3_BUCKET }} 34 | 35 | - name: Invalidate dashboard cloudfront 36 | run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*" 37 | -------------------------------------------------------------------------------- /.github/workflows/example.yml: -------------------------------------------------------------------------------- 1 | name: Example 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | example: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout source code 11 | uses: actions/checkout@v3 12 | 13 | - name: Install dependencies 14 | run: npm ci 15 | 16 | - name: Build 17 | run: npm run build 18 | 19 | - name: Link dependencies 20 | working-directory: ./apps/web 21 | run: npm install 22 | 23 | - name: Cypress tests 24 | working-directory: ./apps/web 25 | run: npx cypress run --browser chrome 26 | 27 | - name: Save cypress traces 28 | if: always() 29 | uses: actions/upload-artifact@v3 30 | with: 31 | name: cypress-trace 32 | path: | 33 | apps/web/dump 34 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | checks: 12 | if: "!contains(toJSON(github.event.commits.*.message), '[skip ci]')" 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout source code 17 | uses: actions/checkout@v3 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Linting 23 | run: npm run lint 24 | 25 | - name: Formatting 26 | run: npm run format 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | checks: 12 | if: "!contains(toJSON(github.event.commits.*.message), '[skip ci]')" 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout source code 17 | uses: actions/checkout@v3 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Unit tests 23 | run: npm run test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | videos 8 | 9 | # testing 10 | coverage 11 | dist 12 | dump 13 | dump_har 14 | screenshots 15 | 16 | # vite 17 | tsconfig.tsbuildinfo 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # turbo 36 | .turbo 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | **/dist 4 | .esbuild 5 | **/dump 6 | .turbo 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | ## Project Structure 4 | 5 | - `packages/cypress-debugger` - the entry point. It exports `debuggerSupport` and `debuggerPlugin` functions, required for plugin installation and a set of types used in the web application. 6 | 7 | - `packages/support` - [Cypress Events](https://docs.cypress.io/api/cypress-api/catalog-of-events) handlers that are responsible for collecting events: 8 | 9 | - cypress steps 10 | - [rrweb](https://www.npmjs.com/package/rrweb) events 11 | - network requests using the [HAR generator cypress plugin](https://github.com/NeuraLegion/cypress-har-generator) 12 | 13 | - `packages/plugin` - exports a function that attaches [Cypress Plugin Events](https://docs.cypress.io/api/plugins/writing-a-plugin) handlers used inside the `setupNodeEvents` function of the cypress configuration. 14 | 15 | Here are collected the browser logs, and created the files with the collected information. The browser logs are accessed using the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) by connecting to the cypress browser using the remote debugging port. The result files are created in the `dump` folder relative to the cypress config file. 16 | 17 | - `meta` - [`RunContextData`](./packages/support/src/cy/runContext.ts) an object with the following fields: 18 | 19 | ```typescript 20 | { 21 | spec: string; // spec filename 22 | test: string[]; // test title 23 | retryAttempt: number; // https://docs.cypress.io/guides/guides/test-retries 24 | } 25 | ``` 26 | 27 | - `browserLogs` - the browser logs at a moment in time. The data is collected using [chrome-remote-interface](https://www.npmjs.com/package/chrome-remote-interface). 28 | 29 | - `pluginMeta` - the data passed down to the optional `meta` field of the `debuggerPlugin` options argument. 30 | 31 | ### Folder structure 32 | 33 | - `packages/cypress-debugger` - the plugin entry point. It exports the `debuggerSupport` and `debuggerPlugin` functions, required for plugin installation and a set of types used in the web application. 34 | 35 | - `packages/support` - exports the function that attaches handlers to [Cypress Events](https://docs.cypress.io/api/cypress-api/catalog-of-events). Here are made the following actions: - collect cypress events - collect [rrweb](https://www.npmjs.com/package/rrweb) events and match them with rrweb events - recording network events (using the [HAR generator cypress plugin](https://github.com/NeuraLegion/cypress-har-generator)) 36 | 37 | - `packages/plugin` - exports a function that attaches [Cypress Plugin Events](https://docs.cypress.io/api/plugins/writing-a-plugin) handlers used inside the `setupNodeEvents` function of the cypress configuration. Here are collected the browser logs, and created the files with the collected information. 38 | The browser logs are accessed using the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) by connecting to the cypress browser using the remote debugging port. 39 | The result files are created in the `dump` folder relative to the cypress config file. 40 | - `apps/web` - the web application. 41 | The Web application is a basic UI created with [Vite](https://vitejs.dev/), [React](https://react.dev/) and [Typescript](https://www.typescriptlang.org/). It is a tool that helps to analyze the information from the files generated by the plugin. It also serves as an example for plugin integration. 42 | - `packages/eslint-config-custom` - shared [eslint](https://eslint.org/) configuration. 43 | 44 | - `packages/tsconfig` - shared [tsconfig](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html). 45 | 46 | ## Development 47 | 48 | Start the packages in development mode 49 | 50 | ```sh 51 | npm install 52 | npm run dev 53 | ``` 54 | 55 | Runs a few tests 56 | 57 | ```sh 58 | cd apps/web 59 | npx cypress run --browser chrome 60 | ``` 61 | 62 | ## Data Structure 63 | 64 | The plugin generates a `json` file for each test into the `dump` folder inside the working directory. Each file contains the following fields: 65 | 66 | - `cy` - a list of cypress events. The data is collected from the cypress [`log:added`](https://docs.cypress.io/api/cypress-api/catalog-of-events) event. 67 | 68 | - `rr` - a list of [rrweb](https://www.npmjs.com/package/rrweb) records, which represents the mutations in the DOM. The entries are linked to `cy` events on cypress `log:added` and `log:changed` events. 69 | 70 | - `har` - an [HTTPArchive(HAR)](http://www.softwareishard.com/blog/har-12-spec/) object, recorded by the [HttpArchive Generator](https://github.com/NeuraLegion/cypress-har-generator). 71 | 72 | - `meta` - [`RunContextData`](./packages/support/src/cy/runContext.ts) an object with the following fields: 73 | 74 | ```typescript 75 | { 76 | spec: string; // spec filename 77 | test: string[]; // test title 78 | retryAttempt: number; // https://docs.cypress.io/guides/guides/test-retries 79 | } 80 | ``` 81 | 82 | - `browserLogs` - the browser logs collected using [chrome-remote-interface](https://www.npmjs.com/package/chrome-remote-interface). 83 | 84 | - `pluginMeta` - the data passed down to the optional `meta` field of the `debuggerPlugin` options argument. 85 | 86 | ## Releasing 87 | 88 | The project uses [Changesets](https://github.com/changesets/changesets) to manage releases. 89 | 90 | ### Beta channel 91 | 92 | When you want to do a prerelease, you need to enter prerelease mode. You can do that with the `pre enter `. The tag that you need to pass is used in versions (e.g. `1.0.0-beta.0`) and for the npm dist tag. 93 | 94 | Please check [Changesets Prereleases](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) for reference. 95 | 96 | Enter prerelease mode and made the first prerelease 97 | 98 | ```sh 99 | npx changeset pre enter beta 100 | npx changeset version 101 | git add . 102 | git commit -m "chore: release vX.X.X-beta.X" 103 | 104 | npm run publish -- -- beta 105 | 106 | git tag "vX.X.X-beta.X" 107 | git push --follow-tags 108 | ``` 109 | 110 | To add another prerelease, run: 111 | 112 | ```sh 113 | npx changeset version 114 | git add . 115 | git commit -m "Version packages" 116 | npm run publish-npm -- -- beta --otp=XXXXXX 117 | 118 | git tag "x.x.x-beta.x" 119 | git push --follow-tags 120 | ``` 121 | 122 | To exit the prerelease mode: 123 | 124 | ```sh 125 | npx changeset pre exit 126 | ``` 127 | 128 | ### Latest channel 129 | 130 | ```sh 131 | npx changeset 132 | # follow screen instructions 133 | npx changeset version 134 | # review the updated files 135 | git add . 136 | git commit -m "chore: release vX.X.X" 137 | 138 | npm run publish-npm -- -- latest --otp=XXXXX 139 | 140 | git tag "vX.X.X" 141 | git push --follow-tags 142 | # create a new release on github 143 | ``` 144 | 145 | ### Localhost 146 | 147 | ```sh 148 | docker run -it --rm --name verdaccio -p 4873:4873 verdaccio/verdaccio 149 | npm adduser --registry http://localhost:4873 150 | npm login --registry http://localhost:4873 151 | npm_config_registry=http://localhost:4873 npm run publish-npm -- -- latest 152 | 153 | # use the new package 154 | npm_config_registry=http://localhost:4873 npm i cypress-debugger --@currents:registry=http://localhost:4873 155 | ``` 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cypress Debugger 2 | 3 | Capture and replay Cypress Tests. Debug your failed and flaky CI cypress tests by replaying execution traces. 4 | 5 | - Cypress test execution steps 6 | - DOM snapshots 7 | - network requests (HAR) 8 | - browser console logs 9 | 10 | The plugin captures and replays everything that's happening in Cypress tests, think of it as Playwright traces for Cypress. The player is available at: https://cypress-debugger.dev 11 | 12 |

13 | 14 |

15 | 16 |

17 | Video Demo | Player | Sorry Cypress | Currents 18 |

19 | 20 | ## Requirements 21 | 22 | - Cypress version 10+ 23 | - NodeJS [^14.17.0](https://docs.cypress.io/guides/getting-started/installing-cypress#:~:text=If%20you're%20using%20npm,Node.js%2014.x) 24 | - Chromium family browsers only 25 | - Requires [alternative cypress binaries](https://docs.currents.dev/getting-started/cypress/integrating-with-cypress/alternative-cypress-binaries) due to [Cypress.io blocking](https://currents.dev/posts/v13-blocking) 26 | 27 | ## Setup 28 | 29 | Install the package: 30 | 31 | ```sh 32 | npm install cypress-debugger 33 | ``` 34 | 35 | Add `cypress-debugger` to `cypress.config.{js|ts|mjs}` 36 | 37 | ```js 38 | // cypress.config.js 39 | const { defineConfig } = require('cypress'); 40 | const { debuggerPlugin } = require('cypress-debugger'); 41 | module.exports = defineConfig({ 42 | e2e: { 43 | setupNodeEvents(on, config) { 44 | debuggerPlugin(on, config, { 45 | meta: { 46 | key: 'value', 47 | }, 48 | // path: absolute path to the dump file 49 | // data: captured data 50 | callback: (path, data) => { 51 | console.log({ 52 | path, 53 | data, 54 | }); 55 | }, 56 | }); 57 | return config; 58 | }, 59 | }, 60 | }); 61 | ``` 62 | 63 | Add `cypress-debugger` to `cypress/support/e2e.{js|ts}` 64 | 65 | ```js 66 | // cypress/support/e2e.js 67 | const { debuggerSupport } = require('cypress-debugger'); 68 | debuggerSupport(); 69 | ``` 70 | 71 | ## Usage 72 | 73 | Configure the plugin as documented above. Use the `callback` function to fetch the location of the replay file you can open in the player. Get the test execution information from the `dump` directory, relative to the cypress configuration file. 74 | 75 | Analyze the information using the debugger web app. 76 | 77 | ### Chrome / Chromium 78 | 79 | ```sh 80 | npx cypress run --browser chrome 81 | ``` 82 | 83 | ### Electron 84 | 85 | Set the `remote-debugging-port` via `ELECTRON_EXTRA_LAUNCH_ARGS` environment variable: 86 | 87 | ```sh 88 | ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9222 npx cypress run --browser electron 89 | ``` 90 | 91 | ## Example 92 | 93 | - See an example in [apps/web](https://github.com/currents-dev/cypress-debugger//blob/main/apps/web) directory 94 | - Example of integrating with Currents: https://github.com/currents-dev/gh-actions-example/tree/debugger-example 95 | 96 | ## API 97 | 98 | ### Plugin: `debuggerPlugin` 99 | 100 | Installs cypress-debugger. 101 | 102 | ```ts 103 | debuggerPlugin(on: Cypress.PluginEvents, config: Cypress.PluginConfig, options?: PluginOptions): void 104 | ``` 105 | 106 | - `on` - [`Cypress.PluginEvents`](https://docs.cypress.io/guides/references/configuration#setupNodeEvents) `setupNodeEvents` method first argument 107 | - `config` - [`Cypress.PluginConfig`](https://docs.cypress.io/guides/references/configuration#setupNodeEvents) `setupNodeEvents` method second argument 108 | - `options` - [`PluginOptions`](./packages/plugin/src/types.ts): 109 | - `meta: Record`: an optional field that is added to the `TestExecutionResult` as `pluginMeta` 110 | - `callback: (path: string, data: TestExecutionResult`: a callback function that will be called after each test 111 | - `targetDirectory: string`: the path to the reports directory. Default is `dump` 112 | - `failedTestsOnly: boolean`: whether to generate debug traces for failed tests only, default is `false` 113 | 114 | Example: 115 | 116 | ```js 117 | // cypress.config.js 118 | const { defineConfig } = require('cypress'); 119 | const { debuggerPlugin } = require('cypress-debugger'); 120 | module.exports = defineConfig({ 121 | e2e: { 122 | setupNodeEvents(on, config) { 123 | return debuggerPlugin(on, config, { 124 | meta: { 125 | key: 'value', 126 | }, 127 | callback: (path, data) => { 128 | console.log({ path, data }); 129 | }, 130 | targetDirectory: 'cypress/e2e/reports', 131 | }); 132 | }, 133 | }, 134 | }); 135 | ``` 136 | 137 | In order to generate traces for failing tests only, set the `failedTestsOnly` configuration to `true` 138 | 139 | Example: 140 | 141 | ```js 142 | module.exports = defineConfig({ 143 | e2e: { 144 | setupNodeEvents(on, config) { 145 | return debuggerPlugin(on, config, { 146 | failedTestsOnly: true, 147 | }); 148 | }, 149 | }, 150 | }); 151 | ``` 152 | 153 | ### Support File: `debuggerSupport` 154 | 155 | Attaches required handlers to [Cypress events](https://docs.cypress.io/api/cypress-api/catalog-of-events) 156 | 157 | ```typescript 158 | debuggerSupport(): void 159 | ``` 160 | 161 | ## Troubleshooting 162 | 163 | Our example setup is working with Chromim-based (Electron and Chrome / Chromium) browsers. We have also created an [example CI integration with GitHub](https://github.com/currents-dev/cypress-debugger/blob/main/.github/workflows/example.yml). Most chances, your existing configuration is more complex and there are additional plugins that interfere with how this plugins works. 164 | 165 | - Try to simplify your configuration until you get a working example as appears in the example [apps/web](https://github.com/currents-dev/cypress-debugger//blob/main/apps/web) 166 | - Slowly enable the rest of the plugins, one-by-one until you face the issue 167 | - Use the debug mode to identify possible root cause: `NODE_DEBUG=cypress-har-generator* DEBUG=cypress:*,cypress-debugger* ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=9226 npx cypress run --browser electron` 168 | - If you found a workaround, submit a contribution with code or documentation improvement 169 | - If you found a bug, submit a [new issue](https://github.com/currents-dev/cypress-debugger/issues/new) with all the details and suggestion 170 | 171 | ## Disclaimer 172 | 173 | All third party trademarks and references (including logos and icons) referenced herein are the property of their respective owners. Unless specifically designated as Made by Currents, integrations are not supported or maintained by Currents. The third party products or services that this software connects to are subject to their respective owners intellectual property and terms of service agreements. 174 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['custom/eslint-react'], 3 | parserOptions: { 4 | root: true, 5 | tsconfigRootDir: __dirname, 6 | project: ['./tsconfig.json'], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | ## 1.0.0 4 | 5 | ### Major Changes 6 | 7 | - First release 8 | 9 | ### Patch Changes 10 | 11 | - Updated dependencies 12 | - cypress-debugger@1.0.0 13 | -------------------------------------------------------------------------------- /apps/web/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress'); 2 | const { debuggerPlugin } = require('cypress-debugger'); 3 | 4 | module.exports = defineConfig({ 5 | e2e: { 6 | baseUrl: 'https://todomvc.com/examples/backbone', 7 | specPattern: 'cypress/e2e/*.spec.js', 8 | supportFile: 'cypress/support/e2e.ts', 9 | setupNodeEvents(on, config) { 10 | debuggerPlugin(on, config, { 11 | meta: { 12 | key: 'value', 13 | }, 14 | callback: (file) => { 15 | // executed after each test 16 | console.log('\t🎥 Trace file %s', file); 17 | }, 18 | }); 19 | 20 | return config; 21 | }, 22 | }, 23 | projectId: '9aOuF6', 24 | video: true, 25 | videoUploadOnPasses: false, 26 | }); 27 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/clear.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('Clear completed button', function () { 6 | beforeEach(function () { 7 | cy.createDefaultTodos().as('todos'); 8 | }); 9 | 10 | it('should display the correct text', function () { 11 | cy.get('@todos').eq(0).find('.toggle').check(); 12 | 13 | cy.get('.clear-completed').contains('Clear completed'); 14 | }); 15 | 16 | it('should remove completed items when clicked', function () { 17 | cy.get('@todos').eq(1).find('.toggle').check(); 18 | 19 | cy.get('.clear-completed').click(); 20 | cy.get('@todos').should('have.length', 2); 21 | cy.get('.todo-list li').eq(0).should('contain', TODO_ITEM_ONE); 22 | cy.get('.todo-list li').eq(1).should('contain', TODO_ITEM_THREE); 23 | }); 24 | 25 | it('should be hidden when there are no items that are completed', function () { 26 | cy.get('@todos').eq(1).find('.toggle').check(); 27 | 28 | cy.get('.clear-completed').should('be.visible').click(); 29 | 30 | cy.get('.clear-completed').should('not.be.visible'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/completed.spec.js: -------------------------------------------------------------------------------- 1 | context('Mark all as completed', function () { 2 | // New commands used here: 3 | // - cy.check https://on.cypress.io/api/check 4 | // - cy.uncheck https://on.cypress.io/api/uncheck 5 | 6 | beforeEach(function () { 7 | // This is an example of aliasing 8 | // within a hook (beforeEach). 9 | // Aliases will automatically persist 10 | // between hooks and are available 11 | // in your tests below 12 | cy.createDefaultTodos().as('todos'); 13 | }); 14 | 15 | it('should allow me to mark all items as completed', function () { 16 | // complete all todos 17 | // we use 'check' instead of 'click' 18 | // because that indicates our intention much clearer 19 | cy.get('.toggle-all').check(); 20 | 21 | // get each todo li and ensure its class is 'completed' 22 | cy.get('@todos').eq(0).should('have.class', 'completed'); 23 | 24 | cy.get('@todos').eq(1).should('have.class', 'completed'); 25 | 26 | cy.get('@todos').eq(2).should('have.class', 'completed'); 27 | }); 28 | 29 | it('should allow me to clear the complete state of all items', function () { 30 | // check and then immediately uncheck 31 | cy.get('.toggle-all').check().uncheck(); 32 | 33 | cy.get('@todos').eq(0).should('not.have.class', 'completed'); 34 | 35 | cy.get('@todos').eq(1).should('not.have.class', 'completed'); 36 | 37 | cy.get('@todos').eq(2).should('not.have.class', 'completed'); 38 | }); 39 | 40 | it('complete all checkbox should update state when items are completed / cleared', function () { 41 | // alias the .toggle-all for reuse later 42 | cy.get('.toggle-all') 43 | .as('toggleAll') 44 | .check() 45 | // this assertion is silly here IMO but 46 | // it is what TodoMVC does 47 | .should('be.checked'); 48 | 49 | // alias the first todo and then click it 50 | cy.get('.todo-list li').eq(0).as('firstTodo').find('.toggle').uncheck(); 51 | 52 | // reference the .toggle-all element again 53 | // and make sure its not checked 54 | cy.get('@toggleAll').should('not.be.checked'); 55 | 56 | // reference the first todo again and now toggle it 57 | cy.get('@firstTodo').find('.toggle').check(); 58 | 59 | // assert the toggle all is checked again 60 | cy.get('@toggleAll').should('be.checked'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/counter.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('Counter', function () { 6 | it('should display the current number of todo items', function () { 7 | cy.createTodo(TODO_ITEM_ONE); 8 | cy.get('.todo-count').contains('1 item left'); 9 | cy.createTodo(TODO_ITEM_TWO); 10 | cy.get('.todo-count').contains('2 items left'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/edit.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('Editing', function () { 6 | // New commands used here: 7 | // - cy.blur https://on.cypress.io/api/blur 8 | 9 | beforeEach(function () { 10 | cy.createDefaultTodos().as('todos'); 11 | }); 12 | 13 | it('should hide other controls when editing', function () { 14 | cy.get('@todos').eq(1).as('secondTodo').find('label').dblclick(); 15 | 16 | cy.get('@secondTodo').find('.toggle').should('not.be.visible'); 17 | 18 | cy.get('@secondTodo').find('label').should('not.be.visible'); 19 | }); 20 | 21 | it('should save edits on blur', function () { 22 | cy.get('@todos').eq(1).as('secondTodo').find('label').dblclick(); 23 | 24 | cy.get('@secondTodo') 25 | .find('.edit') 26 | .clear() 27 | .type('buy some sausages') 28 | // we can just send the blur event directly 29 | // to the input instead of having to click 30 | // on another button on the page. though you 31 | // could do that its just more mental work 32 | .blur(); 33 | 34 | cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE); 35 | 36 | cy.get('@secondTodo').should('contain', 'buy some sausages'); 37 | cy.get('@todos').eq(2).should('contain', TODO_ITEM_THREE); 38 | }); 39 | 40 | it('should trim entered text', function () { 41 | cy.get('@todos').eq(1).as('secondTodo').find('label').dblclick(); 42 | 43 | cy.get('@secondTodo') 44 | .find('.edit') 45 | .clear() 46 | .type(' buy some sausages ') 47 | .type('{enter}'); 48 | 49 | cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE); 50 | 51 | cy.get('@secondTodo').should('contain', 'buy some sausages'); 52 | cy.get('@todos').eq(2).should('contain', TODO_ITEM_THREE); 53 | }); 54 | 55 | it('should remove the item if an empty text string was entered', function () { 56 | cy.get('@todos').eq(1).as('secondTodo').find('label').dblclick(); 57 | 58 | cy.get('@secondTodo').find('.edit').clear().type('{enter}'); 59 | 60 | cy.get('@todos').should('have.length', 2); 61 | }); 62 | 63 | it('should cancel edits on escape', function () { 64 | cy.get('@todos').eq(1).as('secondTodo').find('label').dblclick(); 65 | 66 | cy.get('@secondTodo').find('.edit').clear().type('foo{esc}'); 67 | 68 | cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE); 69 | 70 | cy.get('@todos').eq(1).should('contain', TODO_ITEM_TWO); 71 | 72 | cy.get('@todos').eq(2).should('contain', TODO_ITEM_THREE); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/item.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('Item', function () { 6 | // New commands used here: 7 | // - cy.clear https://on.cypress.io/api/clear 8 | 9 | it('should allow me to mark items as complete', function () { 10 | // we are aliasing the return value of 11 | // our custom command 'createTodo' 12 | // 13 | // the return value is the
  • in the 14 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo'); 15 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo'); 16 | 17 | cy.get('.todo-list li').eq(0).as('firstTodo'); 18 | cy.get('.todo-list li').eq(0).find('.toggle').check(); 19 | 20 | cy.get('.todo-list li').eq(0).should('have.class', 'completed'); 21 | 22 | cy.get('.todo-list li').eq(1).should('not.have.class', 'completed'); 23 | cy.get('.todo-list li').eq(1).find('.toggle').check(); 24 | 25 | cy.get('.todo-list li').eq(0).should('have.class', 'completed'); 26 | cy.get('@secondTodo').should('have.class', 'completed'); 27 | }); 28 | 29 | it('should allow me to un-mark items as complete', function () { 30 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo'); 31 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo'); 32 | 33 | cy.get('.todo-list li').eq(0).as('firstTodo'); 34 | cy.get('@firstTodo').find('.toggle').check(); 35 | 36 | cy.get('@firstTodo').should('have.class', 'completed'); 37 | cy.get('@secondTodo').should('not.have.class', 'completed'); 38 | 39 | cy.get('@firstTodo').find('.toggle').uncheck(); 40 | 41 | cy.get('@firstTodo').should('not.have.class', 'completed'); 42 | cy.get('@secondTodo').should('not.have.class', 'completed'); 43 | }); 44 | 45 | it('should allow me to edit an item', function () { 46 | cy.createDefaultTodos().as('todos'); 47 | 48 | cy.get('@todos') 49 | .eq(1) 50 | .as('secondTodo') 51 | // TODO: fix this, dblclick should 52 | // have been issued to label 53 | .find('label') 54 | .dblclick(); 55 | 56 | // clear out the inputs current value 57 | // and type a new value 58 | cy.get('@secondTodo') 59 | .find('.edit') 60 | .clear() 61 | .type('buy some sausages') 62 | .type('{enter}'); 63 | 64 | // explicitly assert about the text value 65 | cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE); 66 | 67 | cy.get('@secondTodo').should('contain', 'buy some sausages'); 68 | cy.get('@todos').eq(2).should('contain', TODO_ITEM_THREE); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/labels.spec.js: -------------------------------------------------------------------------------- 1 | // type definitions for Cypress object "cy" 2 | /// 3 | 4 | describe('TodoMVC', function () { 5 | it( 6 | 'adds 2 todos', 7 | { 8 | some: 'thing', 9 | meta: 'data', 10 | }, 11 | function () { 12 | cy.get('.new-todo').type('learn testing{enter}').type('be cool{enter}'); 13 | 14 | cy.get('.todo-list li').should('have.length', 2); 15 | } 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/local.spec.js: -------------------------------------------------------------------------------- 1 | context('Check for console output', function () { 2 | it('should display the correct text', function () { 3 | cy.origin('http://localhost:3000', () => { 4 | cy.visit('/'); 5 | cy.get('label').should( 6 | 'contain', 7 | 'To upload data - click on the text or drag a file into the area' 8 | ); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/new.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('New Todo', function () { 6 | // New commands used here: 7 | // https://on.cypress.io/type 8 | // https://on.cypress.io/eq 9 | // https://on.cypress.io/find 10 | // https://on.cypress.io/contains 11 | // https://on.cypress.io/should 12 | // https://on.cypress.io/as 13 | 14 | it('should allow me to add todo items', function () { 15 | // create 1st todo 16 | cy.get('.new-todo').type(TODO_ITEM_ONE).type('{enter}'); 17 | 18 | // make sure the 1st label contains the 1st todo text 19 | cy.get('.todo-list li') 20 | .eq(0) 21 | .find('label') 22 | .should('contain', TODO_ITEM_ONE); 23 | 24 | // create 2nd todo 25 | cy.get('.new-todo').type(TODO_ITEM_TWO).type('{enter}'); 26 | 27 | // make sure the 2nd label contains the 2nd todo text 28 | cy.get('.todo-list li') 29 | .eq(1) 30 | .find('label') 31 | .should('contain', TODO_ITEM_TWO); 32 | }); 33 | 34 | it('adds items', function () { 35 | // create several todos then check the number of items in the list 36 | cy.get('.new-todo') 37 | .type('todo A{enter}') 38 | .type('todo B{enter}') // we can continue working with same element 39 | .type('todo C{enter}') // and keep adding new items 40 | .type('todo D{enter}'); 41 | 42 | cy.get('.todo-list li').should('have.length', 4); 43 | }); 44 | 45 | it('should clear text input field when an item is added', function () { 46 | cy.get('.new-todo').type(TODO_ITEM_ONE).type('{enter}'); 47 | 48 | cy.get('.new-todo').should('have.text', ''); 49 | }); 50 | 51 | it('should append new items to the bottom of the list', function () { 52 | // this is an example of a custom command 53 | // defined in cypress/support/commands.js 54 | cy.createDefaultTodos().as('todos'); 55 | 56 | // even though the text content is split across 57 | // multiple and elements 58 | // `cy.contains` can verify this correctly 59 | cy.get('.todo-count').contains('3 items left'); 60 | 61 | cy.get('@todos').eq(0).find('label').should('contain', TODO_ITEM_ONE); 62 | 63 | cy.get('@todos').eq(1).find('label').should('contain', TODO_ITEM_TWO); 64 | 65 | cy.get('@todos').eq(2).find('label').should('contain', TODO_ITEM_THREE); 66 | }); 67 | 68 | it('should show #main and #footer when items added', function () { 69 | cy.createTodo(TODO_ITEM_ONE); 70 | cy.get('.main').should('be.visible'); 71 | cy.get('.footer').should('be.visible'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/persistance.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | 5 | context('Persistence', function () { 6 | it('should persist its data', function () { 7 | // mimicking TodoMVC tests 8 | // by writing out this function 9 | function testState() { 10 | cy.get('.todo-list li') 11 | .eq(0) 12 | .should('contain', TODO_ITEM_ONE) 13 | .and('have.class', 'completed'); 14 | 15 | cy.get('.todo-list li') 16 | .eq(1) 17 | .should('contain', TODO_ITEM_TWO) 18 | .and('not.have.class', 'completed'); 19 | } 20 | 21 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo'); 22 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo'); 23 | cy.get('.todo-list li') 24 | .eq(0) 25 | .find('.toggle') 26 | .check() 27 | .then(testState) 28 | 29 | .reload() 30 | .then(testState); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/routing.spec.js: -------------------------------------------------------------------------------- 1 | let TODO_ITEM_ONE = 'buy some cheese'; 2 | let TODO_ITEM_TWO = 'feed the cat'; 3 | let TODO_ITEM_THREE = 'book a doctors appointment'; 4 | context('Routing', function () { 5 | // New commands used here: 6 | // https://on.cypress.io/window 7 | // https://on.cypress.io/its 8 | // https://on.cypress.io/invoke 9 | // https://on.cypress.io/within 10 | 11 | beforeEach(function () { 12 | cy.createDefaultTodos().as('todos'); 13 | }); 14 | 15 | it('should allow me to display active items', function () { 16 | cy.get('@todos').eq(1).find('.toggle').check(); 17 | 18 | cy.get('.filters').contains('Active').click(); 19 | 20 | cy.get('.todo-list li').eq(0).should('contain', TODO_ITEM_ONE); 21 | 22 | cy.get('.todo-list li').eq(1).should('contain', TODO_ITEM_THREE); 23 | }); 24 | 25 | it('should respect the back button', function () { 26 | cy.get('@todos').eq(1).find('.toggle').check(); 27 | 28 | cy.get('.filters').contains('Active').click(); 29 | 30 | cy.get('.filters').contains('Completed').click(); 31 | 32 | cy.get('.todo-list li').should('have.length', 1); 33 | cy.go('back'); 34 | cy.get('.todo-list li').should('have.length', 2); 35 | cy.go('back'); 36 | cy.get('.todo-list li').should('have.length', 3); 37 | }); 38 | 39 | it('should allow me to display completed items', function () { 40 | cy.get('@todos').eq(1).find('.toggle').check(); 41 | cy.get('.filters').contains('Completed').click(); 42 | cy.get('.todo-list li').should('have.length', 1); 43 | }); 44 | 45 | it('should allow me to display all items', function () { 46 | cy.get('@todos').eq(1).find('.toggle').check(); 47 | 48 | cy.get('.filters').contains('Active').click(); 49 | 50 | cy.get('.filters').contains('Completed').click(); 51 | 52 | cy.get('.filters').contains('All').click(); 53 | 54 | cy.get('.todo-list li').should('have.length', 3); 55 | }); 56 | 57 | it('should highlight the currently applied filter', function () { 58 | // using a within here which will automatically scope 59 | // nested 'cy' queries to our parent element 60 | cy.get('.filters').within(function () { 61 | cy.contains('All').should('have.class', 'selected'); 62 | cy.contains('Active').click().should('have.class', 'selected'); 63 | 64 | cy.contains('Completed').click().should('have.class', 'selected'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/smoke.spec.js: -------------------------------------------------------------------------------- 1 | // type definitions for Cypress object "cy" 2 | /// 3 | 4 | describe('TodoMVC', function () { 5 | // a very simple example helpful during presentations 6 | it('adds 2 todos', function () { 7 | cy.get('.new-todo').type('learn testing{enter}').type('be cool{enter}'); 8 | 9 | cy.get('.todo-list li').should('have.length', 2); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/todo.no-items.spec.js: -------------------------------------------------------------------------------- 1 | context('No Todos', function () { 2 | it('should hide #main and #footer', function () { 3 | // Unlike the TodoMVC tests, we don't need to create 4 | // a gazillion helper functions which are difficult to 5 | // parse through. Instead we'll opt to use real selectors 6 | // so as to make our testing intentions as clear as possible. 7 | // 8 | // http://on.cypress.io/get 9 | cy.get('.todo-list li').should('not.exist'); 10 | cy.get('[data-layer="Content"]').should('not.exist'); 11 | cy.get('.footer').should('not.be.visible'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | 39 | // *********************************************** 40 | // This example commands.js shows you how to 41 | // create the custom commands: 'createDefaultTodos' 42 | // and 'createTodo'. 43 | // 44 | // The commands.js file is a great place to 45 | // modify existing commands and create custom 46 | // commands for use throughout your tests. 47 | // 48 | // You can read more about custom commands here: 49 | // https://on.cypress.io/commands 50 | // *********************************************** 51 | 52 | Cypress.Commands.add('createDefaultTodos', function () { 53 | let TODO_ITEM_ONE = 'buy some cheese'; 54 | let TODO_ITEM_TWO = 'feed the cat'; 55 | let TODO_ITEM_THREE = 'book a doctors appointment'; 56 | 57 | // begin the command here, which by will display 58 | // as a 'spinning blue state' in the UI to indicate 59 | // the command is running 60 | let cmd = Cypress.log({ 61 | name: 'create default todos', 62 | message: [], 63 | consoleProps() { 64 | // we're creating our own custom message here 65 | // which will print out to our browsers console 66 | // whenever we click on this command 67 | return { 68 | 'Inserted Todos': [TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE], 69 | }; 70 | }, 71 | }); 72 | 73 | // additionally we pass {log: false} to all of our 74 | // sub-commands so none of them will output to 75 | // our command log 76 | 77 | cy.get('.new-todo', { log: false }) 78 | .type(`${TODO_ITEM_ONE}{enter}`, { log: false }) 79 | .type(`${TODO_ITEM_TWO}{enter}`, { log: false }) 80 | .type(`${TODO_ITEM_THREE}{enter}`, { log: false }); 81 | 82 | cy.get('.todo-list li', { log: false }).then(function ($listItems) { 83 | // once we're done inserting each of the todos 84 | // above we want to return the .todo-list li's 85 | // to allow for further chaining and then 86 | // we want to snapshot the state of the DOM 87 | // and end the command so it goes from that 88 | // 'spinning blue state' to the 'finished state' 89 | cmd.set({ $el: $listItems }).snapshot().end(); 90 | }); 91 | }); 92 | 93 | Cypress.Commands.add('createTodo', function (todo) { 94 | let cmd = Cypress.log({ 95 | name: 'create todo', 96 | message: todo, 97 | consoleProps() { 98 | return { 99 | 'Inserted Todo': todo, 100 | }; 101 | }, 102 | }); 103 | 104 | // create the todo 105 | cy.get('.new-todo', { log: false }).type(`${todo}{enter}`, { log: false }); 106 | 107 | // now go find the actual todo 108 | // in the todo list so we can 109 | // easily alias this in our tests 110 | // and set the $el so its highlighted 111 | cy.get('.todo-list', { log: false }) 112 | .contains('li', todo.trim(), { log: false }) 113 | .then(function ($li) { 114 | // set the $el for the command so 115 | // it highlights when we hover over 116 | // our command 117 | cmd.set({ $el: $li }).snapshot().end(); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /apps/web/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 |
    10 | 11 | 12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /apps/web/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/react18'; 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount; 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount); 37 | 38 | // Example use: 39 | // cy.mount() 40 | -------------------------------------------------------------------------------- /apps/web/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import { debuggerSupport } from 'cypress-debugger'; 2 | import './commands'; 3 | 4 | debuggerSupport(); 5 | 6 | beforeEach(() => { 7 | cy.visit('/'); 8 | }); 9 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Debugger 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "vite --host", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint src --fix" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-accordion": "^1.1.1", 13 | "@radix-ui/react-collapsible": "^1.0.2", 14 | "@radix-ui/react-tabs": "^1.0.3", 15 | "@radix-ui/react-toast": "^1.1.3", 16 | "@radix-ui/react-toggle": "^1.0.2", 17 | "@radix-ui/react-tooltip": "^1.0.5", 18 | "class-variance-authority": "^0.5.2", 19 | "clsx": "^1.2.1", 20 | "cypress": "^12.8.1", 21 | "cypress-debugger": "*", 22 | "lodash": "^4.17.21", 23 | "lucide-react": "^0.162.0", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-router-dom": "^6.9.0", 27 | "rrweb-player": "^1.0.0-alpha.4", 28 | "tailwind-merge": "^1.12.0", 29 | "tailwindcss-animate": "^1.0.5", 30 | "url-parse": "^1.5.10" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18.0.28", 34 | "@types/react-dom": "^18.0.11", 35 | "@types/url-parse": "^1.4.8", 36 | "@vitejs/plugin-react": "^3.1.0", 37 | "eslint": "^8.36.0", 38 | "eslint-config-custom": "*", 39 | "tailwindcss": "^3.3.1", 40 | "tsconfig": "*", 41 | "typescript": "^5.1.6", 42 | "vite": "^4.2.3", 43 | "vite-plugin-eslint": "^1.8.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/public/cypress-debugger-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /apps/web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from '@/components/ui/Toaster'; 2 | import { Route, BrowserRouter as Router, Routes } from 'react-router-dom'; 3 | import Layout from './components/Layout'; 4 | import { useDarkMode } from './components/ui/useDarkMode'; 5 | import CypresEventsContextProvider from './context/cypressEvents'; 6 | import HttpArchiveContextProvider from './context/httpArchiveEntries'; 7 | import PlaybackProvider from './context/playback'; 8 | import ReplayerContextProvider from './context/replayer'; 9 | 10 | export default function App() { 11 | const [isDark] = useDarkMode(); 12 | 13 | return ( 14 | 15 |
    16 | 17 | 18 | 19 | 20 | 21 | } /> 22 | 23 | 24 | 25 | 26 | 27 | 28 |
    29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/src/components/Console.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Collapsible, 3 | CollapsibleContent, 4 | CollapsibleTrigger, 5 | } from '@/components/ui/Collapsible'; 6 | import clsx from 'clsx'; 7 | import { 8 | BrowserLog, 9 | RuntimeStackTrace, 10 | TestExecutionResult, 11 | } from 'cypress-debugger'; 12 | import { orderBy } from 'lodash'; 13 | import { ChevronDown, ChevronRight } from 'lucide-react'; 14 | import { useState } from 'react'; 15 | 16 | type Log = { 17 | message?: string; 18 | type: string; 19 | timestamp: number; 20 | stackTrace?: RuntimeStackTrace; 21 | }; 22 | 23 | const formatDate = (millis: number): string => new Date(millis).toISOString(); 24 | 25 | function StackTrace({ 26 | trace, 27 | }: { 28 | trace: BrowserLog['runtimeConsoleApiCalled'][0]['stackTrace']; 29 | }) { 30 | return ( 31 |
      32 | {trace?.callFrames.map((frame, i) => ( 33 |
    • 34 |

      35 | at {frame.functionName} ( 36 | {frame.url}:{frame.lineNumber} 37 | ) 38 |

      39 |
    • 40 | ))} 41 |
    42 | ); 43 | } 44 | 45 | function Message({ 46 | message, 47 | type, 48 | timestamp, 49 | expanded, 50 | }: { 51 | message: string; 52 | type: string; 53 | timestamp: number; 54 | expanded: boolean; 55 | }) { 56 | return ( 57 |
    66 |
    67 | [{type}] 68 | {formatDate(timestamp)} 69 |
    70 | 71 |

    72 | {expanded ? ( 73 | 74 | ) : ( 75 | 76 | )}{' '} 77 | {message} 78 |

    79 |
    80 | ); 81 | } 82 | 83 | function Console({ 84 | logs, 85 | }: { 86 | logs: TestExecutionResult['browserLogs'] | null; 87 | }) { 88 | const [opened, setOpened] = useState([]); 89 | 90 | const handleOpened = (i: number) => { 91 | setOpened((o) => { 92 | if (o.includes(i)) { 93 | const copy = o.slice(); 94 | copy.splice( 95 | o.findIndex((e) => e === i), 96 | 1 97 | ); 98 | return copy; 99 | } 100 | 101 | return [...o, i]; 102 | }); 103 | }; 104 | 105 | if (!logs || !(logs.logEntry.length && logs.runtimeConsoleApiCalled.length)) { 106 | return

    No logs

    ; 107 | } 108 | 109 | const orderedLogs: Log[] = orderBy( 110 | [ 111 | ...logs.logEntry.map((log) => ({ 112 | message: log.text, 113 | type: log.level, 114 | timestamp: log.timestamp, 115 | stackTrace: log.stackTrace, 116 | })), 117 | ...logs.runtimeConsoleApiCalled.map((log) => ({ 118 | message: log.args[0].value, 119 | type: log.type, 120 | timestamp: log.timestamp, 121 | stackTrace: log.stackTrace, 122 | })), 123 | ], 124 | (log) => log.timestamp, 125 | 'asc' 126 | ); 127 | 128 | return ( 129 |
    130 | {orderedLogs.map((log, i) => ( 131 | handleOpened(i)} 134 | className={clsx([ 135 | 'py-3 border-b', 136 | log.type === 'debug' ? 'bg-emerald-50 dark:bg-emerald-950/30' : '', 137 | log.type === 'error' 138 | ? 'bg-red-50 dark:border-red-950 dark:bg-red-900/20' 139 | : '', 140 | log.type === 'warning' ? 'bg-amber-50 dark:bg-amber-500/10' : '', 141 | log.type === 'info' ? 'bg-sky-50 dark:bg-sky-500/10' : '', 142 | ])} 143 | > 144 | 145 | 151 | 152 | 153 | {log.stackTrace ? ( 154 | 155 | ) : ( 156 |

    157 | )} 158 |
    159 |
    160 | ))} 161 |
    162 | ); 163 | } 164 | 165 | export default Console; 166 | -------------------------------------------------------------------------------- /apps/web/src/components/CyEventItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/Button'; 2 | import { useCypressEventsContext } from '@/context/cypressEvents'; 3 | import { testStateVariants } from '@/lib/testState'; 4 | import { cn } from '@/lib/utils'; 5 | import { cva } from 'class-variance-authority'; 6 | import clsx from 'clsx'; 7 | import { CypressEvent } from 'cypress-debugger'; 8 | 9 | const itemVariants = cva( 10 | 'py-2 border-b px-4 hover:bg-slate-200 dark:hover:bg-slate-800 hover:cursor-pointer', 11 | { 12 | variants: { 13 | active: { 14 | default: '', 15 | true: 'bg-slate-300 dark:bg-slate-700', 16 | }, 17 | }, 18 | defaultVariants: { 19 | active: 'default', 20 | }, 21 | } 22 | ); 23 | 24 | function CyEventItem({ 25 | event, 26 | active, 27 | onClick, 28 | }: { 29 | event: CypressEvent; 30 | active: boolean; 31 | onClick: () => void; 32 | }) { 33 | const { payload } = event; 34 | const { setBeforeAfter } = useCypressEventsContext(); 35 | 36 | const showButtons = 37 | (!!event.meta.before.rrId && !!event.meta.before.rrNodes?.length) || 38 | (!!event.meta.after.rrId && !!event.meta.after.rrNodes?.length); 39 | 40 | const onBefore = (e: React.MouseEvent) => { 41 | e.stopPropagation(); 42 | setBeforeAfter('before'); 43 | }; 44 | 45 | const onAfter = (e: React.MouseEvent) => { 46 | e.stopPropagation(); 47 | setBeforeAfter('after'); 48 | }; 49 | 50 | return ( 51 |
  • 56 | 62 | [{payload.state}] 63 | 64 | {payload.type === 'child' && } 65 | {payload.name} 66 |

    67 | {payload.message ? ( 68 | “ {payload.message} ” 69 | ) : ( 70 | payload.message 71 | )} 72 |

    73 | {showButtons && ( 74 |
    75 | 82 | 89 |
    90 | )} 91 |
  • 92 | ); 93 | } 94 | 95 | export default CyEventItem; 96 | -------------------------------------------------------------------------------- /apps/web/src/components/CyEvents.tsx: -------------------------------------------------------------------------------- 1 | import { CypressEvent } from 'cypress-debugger'; 2 | import CyEventItem from './CyEventItem'; 3 | 4 | function CyEvents({ 5 | events, 6 | selectedEvent, 7 | setSelectedEvent, 8 | }: { 9 | events: CypressEvent[]; 10 | selectedEvent: number; 11 | setSelectedEvent: (i: number) => void; 12 | }) { 13 | if (!events.length) return null; 14 | 15 | return ( 16 |
      17 | {events.map((e, i) => ( 18 | setSelectedEvent(i)} 23 | /> 24 | ))} 25 |
    26 | ); 27 | } 28 | 29 | export default CyEvents; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Toggle } from '@/components/ui/Toggle'; 2 | import { useDarkMode } from '@/components/ui/useDarkMode'; 3 | import { Moon, Sun } from 'lucide-react'; 4 | 5 | function DarkModeToggle() { 6 | const [isDark, setIsDark] = useDarkMode(); 7 | 8 | return ( 9 | setIsDark((prevIsDark) => !prevIsDark)} 13 | > 14 | {isDark ? : } 15 | 16 | ); 17 | } 18 | 19 | export default DarkModeToggle; 20 | -------------------------------------------------------------------------------- /apps/web/src/components/EventDetails.tsx: -------------------------------------------------------------------------------- 1 | import { testStateVariants } from '@/lib/testState'; 2 | import { cn } from '@/lib/utils'; 3 | import formatMillis from '@/utils/formatMillis'; 4 | import clsx from 'clsx'; 5 | import { CypressEvent } from 'cypress-debugger'; 6 | import { omit } from 'lodash'; 7 | 8 | export function Entry({ 9 | param, 10 | value, 11 | }: { 12 | param: string; 13 | value: string | number | boolean; 14 | }) { 15 | let displayed = value; 16 | 17 | switch (typeof value) { 18 | case 'string': 19 | displayed = `"${value}"`; 20 | break; 21 | 22 | case 'boolean': 23 | displayed = value ? 'true' : 'false'; 24 | break; 25 | default: 26 | displayed = JSON.stringify(value, null, 2); 27 | break; 28 | } 29 | 30 | return ( 31 |
  • 32 | {param}:  33 | 34 | {displayed} 35 | 36 |
  • 37 | ); 38 | } 39 | 40 | export function EventDetails({ event }: { event: CypressEvent | null }) { 41 | if (!event) { 42 | return

    No event selected

    ; 43 | } 44 | 45 | const parameters = omit(event.payload, 'name', 'wallClockStartedAt'); 46 | 47 | const messageParts = event.payload.message.split(','); 48 | const message = 49 | event.payload.name === 'task' ? messageParts[0] : event.payload.message; 50 | const taskArgs = 51 | event.payload.name === 'task' ? messageParts.slice(1).join(',') : null; 52 | 53 | return ( 54 |
    55 |
    56 | 62 | [{event.payload.state}] 63 | 64 | {event.payload.type === 'child' && } 65 | {event.payload.name} 66 |
      67 | {message && } 68 | {taskArgs && } 69 |
    70 |
    71 |
      72 |
    • Time
    • 73 | 74 | 75 |
    76 |
      77 |
    • Parameters
    • 78 | {Object.entries(parameters).map(([key, value]) => ( 79 | 84 | ))} 85 |
    86 |
    87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /apps/web/src/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@/components/ui/useToast'; 2 | import clsx from 'clsx'; 3 | import { FileUp } from 'lucide-react'; 4 | import { ChangeEvent, DragEvent, useState } from 'react'; 5 | 6 | function FileUpload({ 7 | accept = 'application/json', 8 | onFilesChange, 9 | }: { 10 | accept?: string; 11 | onFilesChange?: (files: FileList | null) => void; 12 | }) { 13 | const { toast } = useToast(); 14 | const [isDragging, setIsDragging] = useState(false); 15 | const handleDragEnter = (e: DragEvent) => { 16 | e.stopPropagation(); 17 | e.preventDefault(); 18 | setIsDragging(true); 19 | }; 20 | 21 | const handleDragLeave = (e: DragEvent) => { 22 | e.stopPropagation(); 23 | e.preventDefault(); 24 | setIsDragging(false); 25 | }; 26 | 27 | const handleDragOver = (e: DragEvent) => { 28 | e.stopPropagation(); 29 | e.preventDefault(); 30 | }; 31 | 32 | const handleDrop = (e: DragEvent) => { 33 | e.stopPropagation(); 34 | e.preventDefault(); 35 | setIsDragging(false); 36 | 37 | const dt = e.dataTransfer; 38 | const files = dt?.files; 39 | 40 | if ( 41 | accept && 42 | [...files].every((file) => file.type && accept.includes(file.type)) 43 | ) { 44 | if (onFilesChange) { 45 | onFilesChange(files); 46 | } 47 | } else { 48 | toast({ 49 | title: 'Error', 50 | description: 'Bad file type', 51 | }); 52 | } 53 | }; 54 | 55 | const handleFilesChange = (e: ChangeEvent) => { 56 | if (!onFilesChange) return; 57 | 58 | onFilesChange(e.target.files); 59 | }; 60 | 61 | return ( 62 |
    72 | 76 | 77 |

    78 | Drop file to upload 79 |
    80 | or 81 |

    82 | 96 |
    97 | ); 98 | } 99 | 100 | export default FileUpload; 101 | -------------------------------------------------------------------------------- /apps/web/src/components/JsonFileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@/components/ui/useToast'; 2 | import FileUpload from './FileUpload'; 3 | 4 | interface JsonFileUploadProps { 5 | onChange: ({ 6 | filename, 7 | payload, 8 | }: { 9 | filename: string | null; 10 | payload: T | null; 11 | }) => void; 12 | validate: (payload: T) => boolean; 13 | } 14 | 15 | function JsonFileUpload({ 16 | onChange, 17 | validate, 18 | }: JsonFileUploadProps) { 19 | const { toast } = useToast(); 20 | 21 | const handleFilesChange = (files: FileList | null) => { 22 | if (!files || !files[0]) return; 23 | 24 | const file = files[0]; 25 | 26 | const reader = new FileReader(); 27 | reader.readAsText(file, 'UTF-8'); 28 | 29 | function handleFileLoad(evt: ProgressEvent) { 30 | let result = evt.target?.result; 31 | 32 | if (!result) { 33 | onChange({ 34 | filename: null, 35 | payload: null, 36 | }); 37 | } else { 38 | if (typeof result !== 'string') { 39 | const textDecoder = new TextDecoder('utf-8'); 40 | result = textDecoder.decode(result); 41 | } 42 | 43 | const parsedResult = JSON.parse(result); 44 | 45 | if (!validate(parsedResult)) { 46 | reader.abort(); 47 | toast({ 48 | title: 'Error', 49 | description: 'Bad input', 50 | }); 51 | } else { 52 | onChange({ 53 | filename: file.name, 54 | payload: parsedResult, 55 | }); 56 | } 57 | } 58 | } 59 | 60 | reader.addEventListener('load', handleFileLoad); 61 | reader.addEventListener('loadend', () => { 62 | reader.removeEventListener('load', handleFileLoad); 63 | }); 64 | 65 | reader.addEventListener('error', () => { 66 | toast({ 67 | title: 'Error', 68 | description: 'Error while uploading the file', 69 | }); 70 | }); 71 | }; 72 | 73 | return ( 74 | 75 | ); 76 | } 77 | 78 | export default JsonFileUpload; 79 | -------------------------------------------------------------------------------- /apps/web/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'; 2 | import { useCypressEventsContext } from '@/context/cypressEvents'; 3 | import { useHttpArchiveContext } from '@/context/httpArchiveEntries'; 4 | import { useReplayerContext } from '@/context/replayer'; 5 | import { usePayloadQueryParam } from '@/hooks/useQuery'; 6 | import { testStateVariants } from '@/lib/testState'; 7 | import { cn } from '@/lib/utils'; 8 | import Console from './Console'; 9 | import CyEvents from './CyEvents'; 10 | import DarkModeToggle from './DarkModeToggle'; 11 | import { EventDetails } from './EventDetails'; 12 | import Network from './Network'; 13 | import PayloadHandler from './PayloadHandler'; 14 | import Player from './Player'; 15 | import { Button } from './ui/Button'; 16 | 17 | function GridLayout() { 18 | const { 19 | events, 20 | selectedEvent, 21 | selectedEventObject, 22 | setSelectedEvent, 23 | meta, 24 | browserLogs, 25 | setEvents, 26 | setMeta, 27 | setBrowserLogs, 28 | } = useCypressEventsContext(); 29 | const { origin, setOrigin, setReplayerData } = useReplayerContext(); 30 | const { entries, setHttpArchiveLog } = useHttpArchiveContext(); 31 | const [, , clearQueryParam] = usePayloadQueryParam(); 32 | 33 | const logsCount = 34 | (browserLogs?.logEntry.length ?? 0) + 35 | (browserLogs?.runtimeConsoleApiCalled.length ?? 0); 36 | 37 | const handleClick = () => { 38 | setOrigin(null); 39 | setEvents([]); 40 | setReplayerData([]); 41 | setHttpArchiveLog(null); 42 | setMeta(null); 43 | setBrowserLogs(null); 44 | clearQueryParam(); 45 | }; 46 | 47 | return ( 48 | <> 49 |
    50 | 51 |
    52 | {!origin && ( 53 |
    54 |
    55 | 70 | 71 |
    72 | Web player for traces generated by{' '} 73 | 74 | cypress-debugger 75 | {' '} 76 | plugin. All the data is stored in-browser. 77 |
    78 | The project is not affiliated with Cypress.io, Inc. 79 |
    80 |
    81 |
    82 | )} 83 | {!!origin && ( 84 |
    85 |
    86 |

    87 | Payload from: {origin} 88 |

    89 | 92 |
    93 | {events.length > 0 && ( 94 |
    95 |
    96 | {meta && ( 97 |
    98 |
    99 | {meta?.spec} 100 |
    101 |
    {meta?.test.join(' > ')}
    102 |
    103 | state:{' '} 104 | 111 | {' '} 112 | {meta.state} 113 | 114 |
    115 | {meta.retryAttempt > 0 && ( 116 |
    117 | attempt: {meta.retryAttempt + 1} 118 |
    119 | )} 120 |
    121 | )} 122 | 123 | 124 | Steps 125 | 126 | 127 | 132 | 133 | 134 |
    135 |
    136 | 137 |
    138 |
    139 | 140 | 141 | Step Details 142 | 143 | Network{' '} 144 | {entries.length > 0 ? ( 145 | 146 | {entries.length} 147 | 148 | ) : ( 149 | '' 150 | )} 151 | 152 | 153 | Console{' '} 154 | {logsCount > 0 ? ( 155 | 156 | {logsCount} 157 | 158 | ) : ( 159 | '' 160 | )} 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |
    174 |
    175 | )} 176 |
    177 | )} 178 | 179 | ); 180 | } 181 | 182 | export default GridLayout; 183 | -------------------------------------------------------------------------------- /apps/web/src/components/Network.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from '@/components/ui/Accordion'; 7 | import { 8 | Collapsible, 9 | CollapsibleContent, 10 | CollapsibleTrigger, 11 | } from '@/components/ui/Collapsible'; 12 | import { 13 | HeadersEntity, 14 | HttpArchiveEntry, 15 | HttpArchiveEntryResponse, 16 | } from 'cypress-debugger'; 17 | import { last } from 'lodash'; 18 | 19 | import getUrlProperties from '@/utils/getUrProperties'; 20 | import { ChevronDown, ChevronRight } from 'lucide-react'; 21 | import { useState } from 'react'; 22 | 23 | function NetworkPreview({ entry }: { entry: HttpArchiveEntry }) { 24 | const resource = 25 | last(getUrlProperties(entry.request.url)?.pathname.split('/')) ?? null; 26 | 27 | const contentType = entry.response.content?.mimeType ?? null; 28 | 29 | return ( 30 |
    31 |

    32 | {entry.response.status} {entry.request.method} {resource} 33 |

    34 | {!!contentType && ( 35 |

    36 | {contentType} 37 |

    38 | )} 39 |
    40 | ); 41 | } 42 | 43 | function HighlightedValue({ value }: { value: string | number | boolean }) { 44 | return {value}; 45 | } 46 | 47 | function HeadersEntry({ header }: { header: HeadersEntity }) { 48 | return ( 49 |
  • 50 | :{header.name}: 51 |
  • 52 | ); 53 | } 54 | 55 | function ResponseBody({ 56 | content, 57 | }: { 58 | content: HttpArchiveEntryResponse['content']; 59 | }) { 60 | const [open, setOpen] = useState(false); 61 | 62 | return ( 63 |
      64 |
    • 65 | MIME Type: 66 |
    • 67 |
    • 68 | Size: 69 |
    • 70 |
    • 71 | Compression: 72 |
    • 73 |
    • 74 | 75 | 76 |
      77 | Content 78 | {open ? ( 79 | 80 | ) : ( 81 | 82 | )} 83 |
      84 |
      85 | 86 |
      87 |
      {content.text}
      88 |
      89 |
      90 |
      91 |
    • 92 |
    93 | ); 94 | } 95 | 96 | function NetworkDetails({ entry }: { entry: HttpArchiveEntry }) { 97 | return ( 98 |
    99 |
      100 |
    • URL
    • 101 |
    • 102 | {entry.request.url} 103 |
    • 104 |
    105 | {entry.request.headers && ( 106 |
      107 |
    • 108 | Request Headers 109 |
    • 110 | {entry.request.headers.map((h: HeadersEntity) => ( 111 | 112 | ))} 113 |
    114 | )} 115 | {entry.response.headers && ( 116 |
      117 |
    • 118 | Response Headers 119 |
    • 120 | {entry.response.headers.map((h: HeadersEntity) => ( 121 | 122 | ))} 123 |
    124 | )} 125 | {!!entry.response.content && ( 126 |
    127 |

    Response Body

    128 | 129 |
    130 | )} 131 |
    132 | ); 133 | } 134 | 135 | function Network({ entries }: { entries: HttpArchiveEntry[] }) { 136 | if (entries.length === 0) { 137 | return

    No records

    ; 138 | } 139 | 140 | return ( 141 |
    142 | 143 | {entries.map((e: HttpArchiveEntry, i) => ( 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | ))} 153 | 154 |
    155 | ); 156 | } 157 | 158 | export default Network; 159 | -------------------------------------------------------------------------------- /apps/web/src/components/PayloadHandler.tsx: -------------------------------------------------------------------------------- 1 | import { TestExecutionResult } from 'cypress-debugger'; 2 | import { Loader2 } from 'lucide-react'; 3 | import { useEffect, useState } from 'react'; 4 | import { useCypressEventsContext } from '../context/cypressEvents'; 5 | import { useHttpArchiveContext } from '../context/httpArchiveEntries'; 6 | import { useReplayerContext } from '../context/replayer'; 7 | import usePayloadFetcher from '../hooks/usePayloadFetcher'; 8 | import { usePayloadQueryParam } from '../hooks/useQuery'; 9 | import JsonFileUpload from './JsonFileUpload'; 10 | 11 | function PayloadHandler() { 12 | const [loading, setLoading] = useState(false); 13 | 14 | const { origin, setOrigin, setReplayerData } = useReplayerContext(); 15 | 16 | const { setHttpArchiveLog } = useHttpArchiveContext(); 17 | 18 | const { setEvents, setMeta, setBrowserLogs } = useCypressEventsContext(); 19 | 20 | const [queryParam] = usePayloadQueryParam(); 21 | 22 | const validate = (payload: TestExecutionResult) => 23 | Object.keys(payload).every((key) => 24 | ['id', 'meta', 'cy', 'rr', 'har', 'pluginMeta', 'browserLogs'].includes( 25 | key 26 | ) 27 | ); 28 | 29 | const handleDataChange = (payload: TestExecutionResult | null) => { 30 | setEvents(payload?.cy || []); 31 | setReplayerData(payload?.rr || []); 32 | setHttpArchiveLog(payload?.har || null); 33 | setMeta(payload?.meta ?? null); 34 | setBrowserLogs(payload?.browserLogs || null); 35 | }; 36 | 37 | const handleFileChange = ({ 38 | filename, 39 | payload, 40 | }: { 41 | filename: string | null; 42 | payload: TestExecutionResult | null; 43 | }) => { 44 | setOrigin(filename); 45 | handleDataChange(payload); 46 | }; 47 | 48 | usePayloadFetcher({ 49 | onData: ({ 50 | payload, 51 | param, 52 | }: { 53 | payload: TestExecutionResult; 54 | param: string; 55 | }) => { 56 | if (validate(payload)) { 57 | handleDataChange(payload); 58 | setOrigin(param); 59 | } else { 60 | // eslint-disable-next-line no-console 61 | console.error('Invalid payload URL'); 62 | } 63 | }, 64 | onLoading: setLoading, 65 | }); 66 | 67 | useEffect(() => { 68 | if (!queryParam) { 69 | setOrigin(null); 70 | handleDataChange(null); 71 | } 72 | }, [queryParam]); // eslint-disable-line 73 | 74 | if (loading) { 75 | return ( 76 |
    77 | 78 |
    79 | ); 80 | } 81 | 82 | if (origin) { 83 | return null; 84 | } 85 | 86 | return ; 87 | } 88 | 89 | export default PayloadHandler; 90 | -------------------------------------------------------------------------------- /apps/web/src/components/Player.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { usePlayback } from '@/context/playback'; 4 | import { useReplayerContext } from '@/context/replayer'; 5 | import RRWebPlayer from 'rrweb-player'; 6 | 7 | function Player() { 8 | const divRef = useRef(null); 9 | const playerRef = useRef(null); 10 | 11 | const { playerData, origin } = useReplayerContext(); 12 | const { rrIdOrTs } = usePlayback(); 13 | 14 | useEffect(() => { 15 | if (!origin) return; 16 | if (!divRef.current) return; 17 | 18 | playerRef.current = new RRWebPlayer({ 19 | target: divRef.current, 20 | props: { 21 | width: 800, 22 | height: 600, 23 | autoPlay: false, 24 | events: playerData.map((e) => e.payload), 25 | }, 26 | }); 27 | 28 | return () => playerRef.current?.getReplayer().destroy(); 29 | // eslint-disable-next-line react-hooks/exhaustive-deps 30 | }, [origin]); 31 | 32 | useEffect(() => { 33 | if (!playerRef.current) return; 34 | 35 | const start = playerData[0].timestamp; 36 | 37 | if (typeof rrIdOrTs === 'string') { 38 | const rrNode = playerData.find((e) => e.id === rrIdOrTs); 39 | if (!rrNode) return; 40 | 41 | playerRef.current.goto(rrNode.timestamp - start); 42 | playerRef.current.pause(); 43 | } 44 | if (typeof rrIdOrTs === 'number') { 45 | playerRef.current.goto(rrIdOrTs - start); 46 | playerRef.current.pause(); 47 | } 48 | // eslint-disable-next-line react-hooks/exhaustive-deps 49 | }, [rrIdOrTs, origin]); 50 | 51 | return
    ; 52 | } 53 | 54 | export default Player; 55 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as AccordionPrimitive from '@radix-ui/react-accordion'; 2 | import { ChevronDown } from 'lucide-react'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const Accordion = AccordionPrimitive.Root; 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )); 19 | AccordionItem.displayName = 'AccordionItem'; 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180', 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )); 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 53 |
    {children}
    54 |
    55 | )); 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 57 | 58 | export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; 59 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { cva, VariantProps } from 'class-variance-authority'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const buttonVariants = cva( 7 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 12 | destructive: 13 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 | outline: 15 | 'border border-input hover:bg-accent hover:text-accent-foreground', 16 | secondary: 17 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground', 19 | link: 'underline-offset-4 hover:underline text-primary', 20 | }, 21 | size: { 22 | default: 'h-10 py-2 px-4', 23 | sm: 'h-9 px-3 rounded-md', 24 | lg: 'h-11 px-8 rounded-md', 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: 'default', 29 | size: 'default', 30 | }, 31 | } 32 | ); 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => ( 40 |