├── .github └── workflows │ ├── badges.yml │ └── ci.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── __snapshots__ └── cypress-failed-log-spec.js ├── cypress.config.js ├── cypress ├── e2e │ ├── a.cy.js │ ├── long-name.cy.js │ ├── test-page1.cy.js │ ├── test-page2.cy.js │ └── test-skipping.cy.js ├── fixtures │ └── example.json └── support │ └── e2e.js ├── images └── failed.png ├── on.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── cypress-failed-log-spec.js ├── failed.js ├── index.js └── utils.js ├── test-page1.html ├── test-page2.html ├── test-page2.json └── test └── verify-failed-json.js /.github/workflows/badges.yml: -------------------------------------------------------------------------------- 1 | name: badges 2 | on: 3 | push: 4 | # update README badge only if the README file changes 5 | # or if the package.json file changes, or this file changes 6 | branches: 7 | - main 8 | paths: 9 | - README.md 10 | - package.json 11 | - .github/workflows/badges.yml 12 | schedule: 13 | # update badges every night 14 | # because we have a few badges that are linked 15 | # to the external repositories 16 | - cron: '0 3 * * *' 17 | 18 | jobs: 19 | badges: 20 | name: Badges 21 | runs-on: ubuntu-20.04 22 | steps: 23 | - name: Checkout 🛎 24 | uses: actions/checkout@v2 25 | 26 | - name: Update version badges 🏷 27 | run: npm run badges 28 | 29 | - name: Commit any changed files 💾 30 | uses: stefanzweifel/git-auto-commit-action@v4 31 | with: 32 | commit_message: Updated badges 33 | branch: main 34 | file_pattern: README.md 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 🛎 8 | uses: actions/checkout@v3 9 | 10 | - name: Install and test 📦 11 | uses: cypress-io/github-action@v4 12 | with: 13 | command: npm test 14 | 15 | - name: Semantic Release 🚀 16 | uses: cycjimmy/semantic-release-action@v2 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | failed-*.json 4 | .DS_Store 5 | cypress/screenshots/ 6 | cypress/videos/ 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | save-exact=true 3 | progress=false 4 | package-lock=true 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-failed-log ![cypress version](https://img.shields.io/badge/cypress-10.3.0-brightgreen) 2 | 3 | > Shows the commands from [Cypress.io](https://www.cypress.io) failed test in the terminal. 4 | 5 | [![NPM][npm-icon] ][npm-url] 6 | 7 | [![ci status][ci image]][ci url] 8 | [![semantic-release][semantic-image] ][semantic-url] 9 | [![js-standard-style][standard-image]][standard-url] 10 | [![renovate-app badge][renovate-badge]][renovate-app] 11 | 12 | ## Install 13 | 14 | Add this module as a dev dependency to your project 15 | 16 | ```sh 17 | npm install --save-dev cypress cypress-failed-log 18 | # if using Yarn 19 | yarn add -D cypress-failed-log 20 | ``` 21 | 22 | Then include this module from your [cypress/support/e2e.js](cypress/support/e2e.js) file 23 | 24 | ```js 25 | // in your cypress/support/e2e.js 26 | // or spec file 27 | // https://github.com/bahmutov/cypress-failed-log 28 | require('cypress-failed-log') 29 | // you can use the "import" keyword 30 | import "cypress-failed-log" 31 | ``` 32 | 33 | ### Recommended for v10 34 | 35 | ```js 36 | // cypress.config.js 37 | const { defineConfig } = require('cypress') 38 | 39 | module.exports = defineConfig({ 40 | defaultCommandTimeout: 500, 41 | e2e: { 42 | setupNodeEvents(on, config) { 43 | // https://github.com/bahmutov/cypress-failed-log 44 | require('cypress-failed-log/on')(on) 45 | }, 46 | }, 47 | }) 48 | ``` 49 | 50 | When Cypress runs, you will see commands including the failed one, right in the terminal 51 | 52 | ![Failed terminal output](images/failed.png) 53 | 54 | ## JSON file 55 | 56 | In addition, all failed commands will be saved into a JSON file. The saved JSON file will live in `cypress/logs/` and have the following properties: 57 | 58 | ``` 59 | specName - filename of the spec 60 | title - the name of the test 61 | suiteName - the parent suite name 62 | testName - full name of the test, including the suite name 63 | testError - error message string 64 | testCommands - array of strings, the last failing command is the last item 65 | ``` 66 | 67 | ## Example 68 | 69 | Here is the failed test JSON file contents. The test name, the failure 70 | and each test command before the test are recorded 71 | 72 | ```json 73 | { 74 | "specName": "failing-spec.js", 75 | "title": "loads the About tab", 76 | "suiteName": "Website", 77 | "testName": "Website loads the About tab", 78 | "testError": "Timed out retrying: Expected to find content: 'Join Us' but never did.", 79 | "testCommands": [ 80 | "visit", 81 | "new url https://www.company.com/#/", 82 | "contains a.nav-link, About", 83 | "click", 84 | "new url https://www.company.com/#/about", 85 | "hash", 86 | "assert expected **#/about** to equal **#/about**", 87 | "contains Join Us", 88 | "assert expected **body :not(script):contains(**'Join Us'**), [type='submit'][value~='Join Us']** to exist in the DOM" 89 | ] 90 | } 91 | ``` 92 | 93 | ## Debugging 94 | 95 | To turn on [`debug`](https://github.com/visionmedia/debug#readme) messages in the browser, open in Cypress browser DevTools console and enter `localStorage.debug = 'cypress-failed-log'`, then reload the spec. You should see log messages. 96 | 97 | ### Small print 98 | 99 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2017 100 | 101 | - [@bahmutov](https://twitter.com/bahmutov) 102 | - [glebbahmutov.com](https://glebbahmutov.com) 103 | - [blog](https://glebbahmutov.com/blog) 104 | - - [videos](https://www.youtube.com/glebbahmutov) 105 | - [presentations](https://slides.com/bahmutov) 106 | - [cypress.tips](https://cypress.tips) 107 | 108 | License: MIT - do anything with the code, but don't blame me if it does not work. 109 | 110 | Support: if you find any problems with this module, email / tweet / 111 | [open issue](https://github.com/bahmutov/cypress-failed-log/issues) on Github 112 | 113 | ## MIT License 114 | 115 | Copyright (c) 2017 Gleb Bahmutov <gleb.bahmutov@gmail.com> 116 | 117 | Permission is hereby granted, free of charge, to any person 118 | obtaining a copy of this software and associated documentation 119 | files (the "Software"), to deal in the Software without 120 | restriction, including without limitation the rights to use, 121 | copy, modify, merge, publish, distribute, sublicense, and/or sell 122 | copies of the Software, and to permit persons to whom the 123 | Software is furnished to do so, subject to the following 124 | conditions: 125 | 126 | The above copyright notice and this permission notice shall be 127 | included in all copies or substantial portions of the Software. 128 | 129 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 130 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 131 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 132 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 133 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 134 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 135 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 136 | OTHER DEALINGS IN THE SOFTWARE. 137 | 138 | [npm-icon]: https://nodei.co/npm/cypress-failed-log.svg?downloads=true 139 | [npm-url]: https://npmjs.org/package/cypress-failed-log 140 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 141 | [semantic-url]: https://github.com/semantic-release/semantic-release 142 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg 143 | [standard-url]: http://standardjs.com/ 144 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 145 | [renovate-app]: https://renovateapp.com/ 146 | 147 | [ci image]: https://github.com/bahmutov/cypress-failed-log/workflows/ci/badge.svg?branch=master 148 | [ci url]: https://github.com/bahmutov/cypress-failed-log/actions 149 | -------------------------------------------------------------------------------- /__snapshots__/cypress-failed-log-spec.js: -------------------------------------------------------------------------------- 1 | exports['spec cypress/e2e/a.cy.js finished with'] = { 2 | "totalTests": 4, 3 | "totalFailed": 2, 4 | "totalPassed": 2, 5 | "totalPending": 0, 6 | "totalSkipped": 0 7 | } 8 | 9 | exports['spec cypress/e2e/test-page1.cy.js finished with'] = { 10 | "totalTests": 1, 11 | "totalFailed": 1, 12 | "totalPassed": 0, 13 | "totalPending": 0, 14 | "totalSkipped": 0 15 | } 16 | 17 | exports['spec cypress/e2e/test-page2.cy.js finished with'] = { 18 | "totalTests": 1, 19 | "totalFailed": 1, 20 | "totalPassed": 0, 21 | "totalPending": 0, 22 | "totalSkipped": 0 23 | } 24 | 25 | exports['spec cypress/e2e/long-name.cy.js finished with'] = { 26 | "totalTests": 2, 27 | "totalFailed": 2, 28 | "totalPassed": 0, 29 | "totalPending": 0, 30 | "totalSkipped": 0 31 | } 32 | 33 | exports['saved commands from cypress/e2e/test-page1.cy.js finds aliens'] = [ 34 | "visit test-page1.html", 35 | "log fail on purpose, no such text", 36 | "wrap {foo: bar}", 37 | "assert expected **{ foo: bar }** to deeply equal **{ foo: bar }**", 38 | "contains this text does not exist", 39 | "assert expected **:cy-contains('this text does not exist'), [type='submit'][value~='this text does not exist']** to be **visible**" 40 | ] 41 | 42 | exports['saved commands from cypress/e2e/test-page2.cy.js finds xhr'] = [ 43 | "visit test-page2.html", 44 | "get #triggerXHR", 45 | "click ", 46 | "xhr GET http://localhost:9999/test-page2.json", 47 | "xhr STUBBED GET http://localhost:9999/test-page3.json", 48 | "log fail on purpose, no such text", 49 | "wrap {foo: bar}", 50 | "assert expected **{ foo: bar }** to deeply equal **{ foo: bar }**", 51 | "contains this text does not exist", 52 | "assert expected **:cy-contains('this text does not exist'), [type='submit'][value~='this text does not exist']** to be **visible**" 53 | ] 54 | 55 | exports['saved commands from cypress/e2e/long-name.cy.js 184-188-192-196-2001'] = [ 56 | "log file name too long", 57 | "get .nonexistent-selector", 58 | "assert expected **.nonexistent-selector** to exist in the DOM" 59 | ] 60 | 61 | exports['saved commands from cypress/e2e/long-name.cy.js 184-188-192-196-200-204-208-212-216-220-224-228-232-236-240-244-248-252-2561'] = [ 62 | "log file name too long", 63 | "get .nonexistent-selector", 64 | "assert expected **.nonexistent-selector** to exist in the DOM" 65 | ] 66 | 67 | exports['saved commands from cypress/e2e/a.cy.js has second test (failing)'] = [ 68 | "wrap {foo: 42}", 69 | "its .foo", 70 | "assert expected **42** to equal **2**" 71 | ] 72 | 73 | exports['saved commands from cypress/e2e/a.cy.js has third test (failing)'] = [ 74 | "wrap foo", 75 | "assert expected **foo** to equal **bar**" 76 | ] 77 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | defaultCommandTimeout: 500, 5 | e2e: { 6 | setupNodeEvents(on, config) { 7 | require('./on')(on) 8 | }, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/e2e/a.cy.js: -------------------------------------------------------------------------------- 1 | describe('root suite', () => { 2 | context('first context', () => { 3 | it('has first test (passing)', () => { 4 | cy.wrap('foo').should('be.equal', 'foo') 5 | cy.wait(700) 6 | }) 7 | 8 | it('retries (passing)', { defaultCommandTimeout: 3000 }, () => { 9 | const person = {} 10 | cy.wrap(person).its('name').should('equal', 'Mo') 11 | setTimeout(() => { 12 | person.name = 'Mo' 13 | 14 | setTimeout(() => { 15 | person.name = 'Joe' 16 | }, 2000) 17 | }, 2000) 18 | 19 | cy.wrap(person).its('name').should('equal', 'Joe') 20 | 21 | }) 22 | 23 | it('has second test (failing)', () => { 24 | cy.wrap({ foo: 42 }).its('foo').should('be.equal', 2) 25 | }) 26 | 27 | it('has third test (failing)', () => { 28 | cy.wrap('foo').should('be.equal', 'bar') 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /cypress/e2e/long-name.cy.js: -------------------------------------------------------------------------------- 1 | describe('03-5-7-9-12-15-18-21-24-27-30-33-36-39-42-45-48-51-54-57-60-63-66-69-72-75-78-81-84-87-90-93-96-100-104-108-112-116-120-124-128-132-136-140-144-148-152-156-160-164-168-172-176-180-', () => { 2 | it('184-188-192-196-2001', () => { 3 | cy.log('file name short enough') 4 | cy.visit('https://example.cypress.io').url().should('contain', 'google'); 5 | }) 6 | 7 | it('184-188-192-196-200-204-208-212-216-220-224-228-232-236-240-244-248-252-2561', () => { 8 | cy.log('file name too long') 9 | cy.get('.nonexistent-selector').should('exist') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/e2e/test-page1.cy.js: -------------------------------------------------------------------------------- 1 | describe('cypress failed log', () => { 2 | beforeEach(function openUrl () { 3 | cy.visit('test-page1.html') 4 | }) 5 | 6 | afterEach(function makeDummyCommands () { 7 | // more dummy commands on purpose. Can we get 8 | // the right command list when the actual test failed? 9 | cy 10 | .wait(100) 11 | .wait(100) 12 | .wait(100) 13 | .wait(100) 14 | .wait(100) 15 | .wait(100) 16 | .wait(100) 17 | .wait(100) 18 | }) 19 | 20 | // this test fails on purpose 21 | it('finds aliens', () => { 22 | cy.log('fail on purpose, no such text') 23 | cy 24 | .wrap({ foo: 'bar' }) 25 | .then(o => { 26 | console.log('there is an object') 27 | }) 28 | .should('deep.equal', { foo: 'bar' }) 29 | cy.contains('this text does not exist').should('be.visible') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /cypress/e2e/test-page2.cy.js: -------------------------------------------------------------------------------- 1 | describe('cypress failed log', () => { 2 | beforeEach(function openUrl () { 3 | cy.visit('test-page2.html') 4 | }) 5 | 6 | // this test fails on purpose 7 | it('finds xhr', () => { 8 | cy.server(); 9 | cy.route('GET', '/test-page3.json', ['mock data']) 10 | cy.get('#triggerXHR').click() 11 | cy.log('fail on purpose, no such text') 12 | cy 13 | .wrap({ foo: 'bar' }) 14 | .then(o => { 15 | console.log('there is an object') 16 | }) 17 | .should('deep.equal', { foo: 'bar' }) 18 | cy.contains('this text does not exist').should('be.visible') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /cypress/e2e/test-skipping.cy.js: -------------------------------------------------------------------------------- 1 | describe('cypress skipped tests', () => { 2 | it.skip('Skipping using it.skip', () => {}) 3 | 4 | it('Skipping using this.skip()', function() { 5 | this.skip(); 6 | }) 7 | 8 | it('Skipping using this.skip() again', function() { 9 | this.skip(); 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // load cypress-failed-log 2 | // in the user project this would be 3 | // require('cypress-failed-log') 4 | require('../..') 5 | -------------------------------------------------------------------------------- /images/failed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/cypress-failed-log/35899dfd74b1aca5f23d153b4d444beacc6b68d3/images/failed.png -------------------------------------------------------------------------------- /on.js: -------------------------------------------------------------------------------- 1 | // registers this plugin in the user's Cypress plugin / config.js file 2 | module.exports = function registerPlugin(on) { 3 | on('task', { 4 | failed: require('./src/failed')() 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-failed-log", 3 | "description": "Gets you the Cypress test command log as JSON on failure", 4 | "version": "0.0.0-development", 5 | "author": "Gleb Bahmutov ", 6 | "bugs": "https://github.com/bahmutov/cypress-failed-log/issues", 7 | "engines": { 8 | "node": ">=6" 9 | }, 10 | "files": [ 11 | "on.js", 12 | "src/*.js", 13 | "!src/*-spec.js" 14 | ], 15 | "homepage": "https://github.com/bahmutov/cypress-failed-log#readme", 16 | "keywords": [ 17 | "cypress", 18 | "fail", 19 | "json", 20 | "log", 21 | "report", 22 | "utility" 23 | ], 24 | "license": "MIT", 25 | "main": "src/", 26 | "publishConfig": { 27 | "registry": "https://registry.npmjs.org/" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/bahmutov/cypress-failed-log.git" 32 | }, 33 | "scripts": { 34 | "ban": "ban", 35 | "badges": "npx -p dependency-version-badge update-badge cypress", 36 | "deps": "deps-ok && dependency-check . --unused --no-dev --ignore-module logdown", 37 | "issues": "git-issues", 38 | "license": "license-checker --production --onlyunknown --csv", 39 | "lint": "standard --verbose --fix src/*.js test/*.js", 40 | "pretest": "npm run lint", 41 | "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", 42 | "test": "npm run unit", 43 | "unit": "mocha --timeout 180000 src/*-spec.js", 44 | "postunit": "npm run warn-only", 45 | "semantic-release": "semantic-release", 46 | "failed-test": "echo Test failed, details in $1", 47 | "verify-failed-json": "node test/verify-failed-json.js", 48 | "clean": "rm failed-*.json || true", 49 | "cy:open": "cypress open", 50 | "cy:run": "cypress run", 51 | "warn-only": "stop-only --warn -f src", 52 | "stop-only": "stop-only -f src" 53 | }, 54 | "devDependencies": { 55 | "ban-sensitive-files": "1.9.7", 56 | "cypress": "11.2.0", 57 | "dependency-check": "4.1.0", 58 | "deps-ok": "1.4.1", 59 | "git-issues": "1.3.1", 60 | "lazy-ass": "1.6.0", 61 | "license-checker": "25.0.1", 62 | "lodash": "4.17.15", 63 | "mocha": "7.1.2", 64 | "mocha-banner": "1.1.2", 65 | "prettier-standard": "16.3.0", 66 | "rimraf": "3.0.2", 67 | "semantic-release": "19.0.5", 68 | "snap-shot-it": "7.9.3", 69 | "standard": "14.3.3", 70 | "stop-only": "3.1.0", 71 | "terminal-banner": "1.1.0" 72 | }, 73 | "standard": { 74 | "globals": [ 75 | "cy", 76 | "Cypress", 77 | "beforeEach", 78 | "afterEach" 79 | ] 80 | }, 81 | "dependencies": { 82 | "debug": "4.3.4", 83 | "logdown": "3.3.1" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "prHourlyLimit": 2, 7 | "updateNotScheduled": false, 8 | "timezone": "America/New_York", 9 | "schedule": [ 10 | "every weekend" 11 | ], 12 | "masterIssue": true, 13 | "packageRules": [ 14 | { 15 | "packagePatterns": [ 16 | "*" 17 | ], 18 | "excludePackagePatterns": [ 19 | "debug", 20 | "logdown", 21 | "cypress", 22 | "semantic-release" 23 | ], 24 | "enabled": false 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/cypress-failed-log-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('mocha-banner').register() 4 | 5 | const la = require('lazy-ass') 6 | const cypress = require('cypress') 7 | const snapshot = require('snap-shot-it') 8 | const { existsSync } = require('fs') 9 | const { join } = require('path') 10 | const rimraf = require('rimraf') 11 | const debug = require('debug')('test') 12 | const { terminalBanner } = require('terminal-banner') 13 | const _ = require('lodash') 14 | 15 | function getFilename (specName, title) { 16 | la(specName, 'missing spec name', specName) 17 | la(title, 'missing test title', title) 18 | 19 | function getCleanTestTitle (specName, title) { 20 | const result = _ 21 | .chain([_.split(specName, '.')[0], _.join(title, '-')]) 22 | .join('-') 23 | .deburr() 24 | .kebabCase() 25 | .truncate({ 26 | length: 220, 27 | omission: '' 28 | }) 29 | .value() 30 | 31 | return `failed-${result}.json` 32 | } 33 | 34 | const fileName = getCleanTestTitle(specName, title) 35 | return join(__dirname, '..', 'cypress', 'logs', fileName) 36 | } 37 | 38 | function checkStats (tests) { 39 | const { 40 | totalTests, 41 | totalFailed, 42 | totalPassed, 43 | totalPending, 44 | totalSkipped, 45 | runs: [ 46 | { spec: { name } } 47 | ] 48 | } = tests 49 | const logObj = { 50 | totalTests, 51 | totalFailed, 52 | totalPassed, 53 | totalPending, 54 | totalSkipped 55 | } 56 | debug('finished tests stats %o', logObj) 57 | snapshot(`spec ${name} finished with`, logObj) 58 | return tests 59 | } 60 | 61 | /* global describe, it */ 62 | describe('cypress-failed-log', () => { 63 | beforeEach(() => { 64 | const logsFolder = join(__dirname, '..', 'cypress', 'logs') 65 | debug('deleting folder %s', logsFolder) 66 | rimraf.sync(logsFolder) 67 | }) 68 | 69 | it('runs spec a', () => { 70 | const spec = 'cypress/e2e/a.cy.js' 71 | terminalBanner(`Starting spec ${spec} at ${new Date()}`, '*') 72 | 73 | return cypress 74 | .run({ 75 | spec 76 | }) 77 | .tap(() => { 78 | terminalBanner( 79 | `Cypress run finished for: ${spec} at ${new Date()}`, 80 | '*' 81 | ) 82 | }) 83 | .then(checkStats) 84 | .then(({ runs }) => { 85 | const { spec: { name }, tests } = runs[0] 86 | for (const testInfo of tests) { 87 | debug('test info %o', testInfo) 88 | const { title, state } = testInfo 89 | 90 | if (state === 'failed') { 91 | const filename = getFilename(name, title) 92 | la(existsSync(filename), 'cannot find file', filename) 93 | 94 | const saved = require(filename) 95 | snapshot(`saved commands from ${name} ${_.last(title)}`, saved.testCommands) 96 | } 97 | } 98 | }) 99 | }) 100 | 101 | it('runs spec test-page1', () => { 102 | const spec = 'cypress/e2e/test-page1.cy.js' 103 | terminalBanner(`Starting spec ${spec} at ${new Date()}`, '*') 104 | 105 | return cypress 106 | .run({ 107 | spec 108 | }) 109 | .tap(() => { 110 | terminalBanner( 111 | `Cypress run finished for: ${spec} at ${new Date()}`, 112 | '*' 113 | ) 114 | }) 115 | .then(checkStats) 116 | .then(({ runs }) => { 117 | const { spec: { name }, tests } = runs[0] 118 | for (const { title, state } of tests) { 119 | if (state === 'failed') { 120 | const filename = getFilename(name, title) 121 | la(existsSync(filename), 'cannot find file', filename) 122 | const saved = require(filename) 123 | snapshot(`saved commands from ${name} ${_.last(title)}`, saved.testCommands) 124 | } 125 | } 126 | }) 127 | }) 128 | 129 | it('runs spec test-page2', () => { 130 | const spec = 'cypress/e2e/test-page2.cy.js' 131 | terminalBanner(`Starting spec ${spec} at ${new Date()}`, '*') 132 | 133 | return cypress 134 | .run({ 135 | spec 136 | }) 137 | .tap(() => { 138 | terminalBanner( 139 | `Cypress run finished for: ${spec} at ${new Date()}`, 140 | '*' 141 | ) 142 | }) 143 | .then(checkStats) 144 | .then(({ runs }) => { 145 | const { spec: { name }, tests } = runs[0] 146 | const { title } = tests[0] 147 | 148 | const filename = getFilename(name, title) 149 | la(existsSync(filename), 'cannot find file', filename) 150 | const saved = require(filename) 151 | saved.testCommands = saved.testCommands.map((command) => { 152 | if (command.substring(0, 3) === 'xhr') { 153 | return command.replace(/localhost:[0-9]+/, 'localhost:9999') 154 | } else { 155 | return command 156 | } 157 | }) 158 | snapshot(`saved commands from ${name} ${_.last(title)}`, saved.testCommands) 159 | }) 160 | }) 161 | 162 | it('runs spec long-name', () => { 163 | const spec = 'cypress/e2e/long-name.cy.js' 164 | terminalBanner(`Starting spec ${spec} at ${new Date()}`, '*') 165 | 166 | return cypress 167 | .run({ 168 | spec 169 | }) 170 | .tap(() => { 171 | terminalBanner( 172 | `Cypress run finished for: ${spec} at ${new Date()}`, 173 | '*' 174 | ) 175 | }) 176 | .then(checkStats) 177 | .then(({ runs }) => { 178 | const { spec: { name }, tests } = runs[0] 179 | for (const { title, state } of tests) { 180 | if (state === 'failed') { 181 | const filename = getFilename(name, title) 182 | la(existsSync(filename), 'cannot find file', filename) 183 | 184 | console.log('loading saved output from %s', filename) 185 | const saved = require(filename) 186 | console.log('>>> loaded json <<<') 187 | console.log(JSON.stringify(saved, null, 2)) 188 | 189 | snapshot(`saved commands from ${name} ${_.last(title)}`, saved.testCommands) 190 | } 191 | } 192 | }) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /src/failed.js: -------------------------------------------------------------------------------- 1 | const markdown = require('logdown/src/markdown/node') 2 | 3 | const replaceStarStart = s => s.replace(/\*\*/g, '*') 4 | const formatMarkdown = s => markdown.parse(s).text 5 | 6 | module.exports = () => info => { 7 | // commands we receive use **foo** syntax for bold font 8 | // and markdown module we use needs *foo*, so we first 9 | // replace every '**' with '* 10 | // then print commands to the terminal using Markdown formatting 11 | const formattedCommands = info.testCommands 12 | .map(replaceStarStart) 13 | .map(formatMarkdown) 14 | console.log(formattedCommands.join('\n')) 15 | if (info.filepath) { 16 | console.log('saved as log in: %s', info.filepath) 17 | } 18 | return null 19 | } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-check 3 | 'use strict' 4 | 5 | const path = require('path') 6 | const debug = require('debug')('cypress-failed-log') 7 | 8 | // check built-in module against missing methods 9 | if (typeof path.basename !== 'function') { 10 | throw new Error('path.basename should be a function') 11 | } 12 | 13 | const maxFileNameLength = 220 14 | const cleanupFilename = s => Cypress._.kebabCase(Cypress._.deburr(s)) 15 | const truncateFilename = s => Cypress._.truncate(s, { 16 | length: maxFileNameLength, 17 | omission: '' 18 | }) 19 | const getCleanFilename = s => truncateFilename(cleanupFilename(s)) 20 | const getFilepath = filename => path.join('cypress', 'logs', filename) 21 | const retriesTimes = getRetriesTimes() 22 | 23 | function getRetriesTimes () { 24 | const retries = Cypress.config('retries') 25 | if (Cypress._.isNumber(retries)) { 26 | return retries 27 | } 28 | 29 | if (Cypress._.isObject(retries) && Cypress._.isNumber(retries.runMode)) { 30 | return retries.runMode 31 | } 32 | 33 | return 0 34 | } 35 | 36 | const failedCaseTable = {} 37 | 38 | function writeFailedTestInfo ({ 39 | specName, 40 | title, 41 | suiteName, 42 | testName, 43 | testError, 44 | testCommands 45 | }) { 46 | const info = { 47 | specName, 48 | title, 49 | suiteName, 50 | testName, 51 | testError, 52 | testCommands 53 | } 54 | const str = JSON.stringify(info, null, 2) + '\n' 55 | const cleaned = getCleanFilename( 56 | Cypress._.join([ 57 | Cypress._.split(specName, '.')[0], 58 | testName 59 | ], '-')) 60 | const filename = `failed-${cleaned}.json` 61 | const filepath = getFilepath(filename) 62 | cy 63 | .writeFile(filepath, str) 64 | .log(`saved failed test information to ${filename}`) 65 | 66 | return filepath 67 | } 68 | 69 | let savingCommands = false 70 | let loggedCommands = [] 71 | 72 | function startLogging () { 73 | debug('will log Cypress commands') 74 | 75 | Cypress.on('test:before:run', () => { 76 | debug('before test run') 77 | savingCommands = true 78 | }) 79 | 80 | // should we use command:start or command:end 81 | // or combination of both to keep track? 82 | // hmm, not every command seems to show up in command:end 83 | // Cypress.on('command:end', logCommand) 84 | 85 | Cypress.on('log:added', options => { 86 | if (!savingCommands) { 87 | return 88 | } 89 | if (options.instrument === 'command' && options.consoleProps) { 90 | let detailMessage = '' 91 | if (options.name === 'xhr') { 92 | detailMessage = (options.consoleProps.Stubbed === 'Yes' ? 'STUBBED ' : '') + options.consoleProps.Method + ' ' + options.consoleProps.URL 93 | } 94 | const log = { 95 | message: options.name + ' ' + options.message + (detailMessage !== '' ? ' ' + detailMessage : '') 96 | } 97 | debug(log) 98 | loggedCommands.push(log) 99 | } 100 | }) 101 | 102 | Cypress.on('log:changed', options => { 103 | if (options.instrument === 'command' && options.consoleProps) { 104 | // This is NOT the exact command duration, since we are only 105 | // getting an event some time after the command finishes. 106 | // Still better to have approximate value than nothing 107 | options.wallClockStoppedAt = Date.now() 108 | options.duration = +options.wallClockStoppedAt - (+new Date(options.wallClockStartedAt)) 109 | options.consoleProps.Duration = options.duration 110 | } 111 | }) 112 | } 113 | 114 | function initLog () { 115 | loggedCommands = [] 116 | } 117 | 118 | function onFailed () { 119 | savingCommands = false 120 | if (this.currentTest.state === 'passed' || this.currentTest.state === 'pending') { 121 | return 122 | } 123 | const testName = this.currentTest.fullTitle() 124 | 125 | // remember the test case retry times 126 | if (failedCaseTable[testName]) { 127 | failedCaseTable[testName] += 1 128 | } else { 129 | failedCaseTable[testName] = 1 130 | } 131 | 132 | const title = this.currentTest.title 133 | 134 | const suiteName = this.currentTest.parent && this.currentTest.parent.title 135 | 136 | const testError = this.currentTest.err.message 137 | 138 | const commands = loggedCommands 139 | 140 | // sometimes the message is the same, since the log command events 141 | // repeat when state changes (command starts, runs, etc) 142 | // so filter and cleanup 143 | // const testCommands = reject(commands.filter(notEmpty), duplicate) 144 | const testCommands = Cypress._.map(commands, 'message') 145 | 146 | // const specName = path.basename(window.location.pathname) 147 | const specName = Cypress.spec.relative 148 | 149 | console.log('=== test failed ===') 150 | console.log(specName) 151 | console.log('=== title ===') 152 | console.log(title) 153 | if (suiteName) { 154 | console.log('suite', suiteName) 155 | } 156 | console.log(testName) 157 | console.log('=== error ===') 158 | console.log(testError) 159 | console.log('=== commands ===') 160 | console.log(testCommands.join('\n')) 161 | 162 | const info = { 163 | specName, 164 | title, 165 | suiteName, 166 | testName, 167 | testError, 168 | testCommands 169 | } 170 | 171 | // If finally retry still failed or we didn't set the retry value in cypress.json 172 | // directly to write the failed log 173 | const lastAttempt = failedCaseTable[testName] - 1 === retriesTimes 174 | const noRetries = retriesTimes === 0 175 | debug('no retries %o last attempt %o', noRetries, lastAttempt) 176 | if (noRetries || lastAttempt) { 177 | const filepath = writeFailedTestInfo(info) 178 | debug('saving the log file %s', filepath) 179 | info.filepath = filepath 180 | } 181 | 182 | cy.task('failed', info, { log: false }) 183 | } 184 | 185 | // We have to do a hack to make sure OUR "afterEach" callback function 186 | // runs BEFORE any user supplied "afterEach" callback. 187 | // Otherwise commands executed by the user callback might 188 | // add too many commands to the log, making post-mortem 189 | // triage very difficult. In this case we just wrap client supplied 190 | // "afterEach" function with our callback "onFailed". This ensures we run 191 | // first. 192 | 193 | const _afterEach = afterEach 194 | /* eslint-disable-next-line no-global-assign */ 195 | afterEach = (name, fn) => { 196 | // eslint-disable-line 197 | if (typeof name === 'function') { 198 | fn = name 199 | name = fn.name 200 | } 201 | // run our "onFailed" before running the client function "fn" 202 | _afterEach(name, function () { 203 | // run callbacks with context "this" 204 | onFailed.call(this) 205 | fn.call(this) 206 | }) 207 | } 208 | 209 | startLogging() 210 | beforeEach(initLog) 211 | // register our callback to process failed tests without wrapping 212 | _afterEach(onFailed) 213 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const util = require('util') 4 | 5 | const useSingleQuotes = s => 6 | Cypress._.replace(Cypress._.replace(s, /'/g, "\\'"), /"/g, "'") 7 | 8 | const stringify = x => useSingleQuotes(JSON.stringify(util.inspect(x))) 9 | 10 | const isSimple = x => 11 | Cypress._.isString(x) || Cypress._.isNumber(x) || Cypress._.isPlainObject(x) 12 | 13 | module.exports = { 14 | useSingleQuotes, 15 | isSimple, 16 | stringify 17 | } 18 | -------------------------------------------------------------------------------- /test-page1.html: -------------------------------------------------------------------------------- 1 | 2 | videos 3 |

a test page 1

4 | 5 | -------------------------------------------------------------------------------- /test-page2.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test-page2.json: -------------------------------------------------------------------------------- 1 | { 2 | "some": "test data" 3 | } -------------------------------------------------------------------------------- /test/verify-failed-json.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const relative = path.join.bind(null, __dirname) 8 | const inParentFolder = name => relative('..', name) 9 | 10 | const jsonFiles = [ 11 | 'failed-without-user-after-each-finds-aliens-2.json', 12 | 'failed-cypress-failed-log-finds-aliens.json' 13 | ] 14 | 15 | const logsFolder = inParentFolder('cypress/logs') 16 | console.log('logs in folder', logsFolder) 17 | 18 | function checkJsonFile (filename) { 19 | la(is.unemptyString(filename), 'expected filename', filename) 20 | const jsonFilename = path.join(logsFolder, filename) 21 | la(fs.existsSync(jsonFilename), 'cannot find json file', jsonFilename) 22 | 23 | const result = require(jsonFilename) 24 | la(is.object(result), 'expected an object from', jsonFilename, result) 25 | 26 | la(is.unemptyString(result.specName), 'missing spec file name', result) 27 | 28 | la(is.unemptyString(result.title), 'missing test title', result) 29 | la(is.unemptyString(result.suiteName), 'missing suite name', result) 30 | 31 | la(is.unemptyString(result.testError), 'missing test error', result) 32 | la(is.strings(result.testCommands), 'missing test commands', result) 33 | 34 | la(is.unempty(result.testCommands), 35 | 'should have test commands in', filename, result) 36 | la(is.strings(result.testCommands), 37 | 'test commands should be strings', filename, result) 38 | 39 | la(result.testCommands[0].startsWith('visit'), 40 | 'expected first command to be visit', result.testCommands) 41 | 42 | console.log('file %s looks ok', jsonFilename) 43 | } 44 | 45 | jsonFiles.forEach(checkJsonFile) 46 | --------------------------------------------------------------------------------