cypress-failed-log
Version:
Gets you the Cypress test command log as JSON on failure
213 lines (181 loc) • 5.84 kB
JavaScript
/// <reference types="cypress" />
// @ts-check
const path = require('path')
const debug = require('debug')('cypress-failed-log')
// check built-in module against missing methods
if (typeof path.basename !== 'function') {
throw new Error('path.basename should be a function')
}
const maxFileNameLength = 220
const cleanupFilename = s => Cypress._.kebabCase(Cypress._.deburr(s))
const truncateFilename = s => Cypress._.truncate(s, {
length: maxFileNameLength,
omission: ''
})
const getCleanFilename = s => truncateFilename(cleanupFilename(s))
const getFilepath = filename => path.join('cypress', 'logs', filename)
const retriesTimes = getRetriesTimes()
function getRetriesTimes () {
const retries = Cypress.config('retries')
if (Cypress._.isNumber(retries)) {
return retries
}
if (Cypress._.isObject(retries) && Cypress._.isNumber(retries.runMode)) {
return retries.runMode
}
return 0
}
const failedCaseTable = {}
function writeFailedTestInfo ({
specName,
title,
suiteName,
testName,
testError,
testCommands
}) {
const info = {
specName,
title,
suiteName,
testName,
testError,
testCommands
}
const str = JSON.stringify(info, null, 2) + '\n'
const cleaned = getCleanFilename(
Cypress._.join([
Cypress._.split(specName, '.')[0],
testName
], '-'))
const filename = `failed-${cleaned}.json`
const filepath = getFilepath(filename)
cy
.writeFile(filepath, str)
.log(`saved failed test information to ${filename}`)
return filepath
}
let savingCommands = false
let loggedCommands = []
function startLogging () {
debug('will log Cypress commands')
Cypress.on('test:before:run', () => {
debug('before test run')
savingCommands = true
})
// should we use command:start or command:end
// or combination of both to keep track?
// hmm, not every command seems to show up in command:end
// Cypress.on('command:end', logCommand)
Cypress.on('log:added', options => {
if (!savingCommands) {
return
}
if (options.instrument === 'command' && options.consoleProps) {
let detailMessage = ''
if (options.name === 'xhr') {
detailMessage = (options.consoleProps.Stubbed === 'Yes' ? 'STUBBED ' : '') + options.consoleProps.Method + ' ' + options.consoleProps.URL
}
const log = {
message: options.name + ' ' + options.message + (detailMessage !== '' ? ' ' + detailMessage : '')
}
debug(log)
loggedCommands.push(log)
}
})
Cypress.on('log:changed', options => {
if (options.instrument === 'command' && options.consoleProps) {
// This is NOT the exact command duration, since we are only
// getting an event some time after the command finishes.
// Still better to have approximate value than nothing
options.wallClockStoppedAt = Date.now()
options.duration = +options.wallClockStoppedAt - (+new Date(options.wallClockStartedAt))
options.consoleProps.Duration = options.duration
}
})
}
function initLog () {
loggedCommands = []
}
function onFailed () {
savingCommands = false
if (this.currentTest.state === 'passed') {
return
}
const testName = this.currentTest.fullTitle()
// remember the test case retry times
if (failedCaseTable[testName]) {
failedCaseTable[testName] += 1
} else {
failedCaseTable[testName] = 1
}
const title = this.currentTest.title
const suiteName = this.currentTest.parent && this.currentTest.parent.title
const testError = this.currentTest.err.message
const commands = loggedCommands
// sometimes the message is the same, since the log command events
// repeat when state changes (command starts, runs, etc)
// so filter and cleanup
// const testCommands = reject(commands.filter(notEmpty), duplicate)
const testCommands = Cypress._.map(commands, 'message')
// const specName = path.basename(window.location.pathname)
const specName = Cypress.spec.relative
console.log('=== test failed ===')
console.log(specName)
console.log('=== title ===')
console.log(title)
if (suiteName) {
console.log('suite', suiteName)
}
console.log(testName)
console.log('=== error ===')
console.log(testError)
console.log('=== commands ===')
console.log(testCommands.join('\n'))
const info = {
specName,
title,
suiteName,
testName,
testError,
testCommands
}
// If finally retry still failed or we didn't set the retry value in cypress.json
// directly to write the failed log
const lastAttempt = failedCaseTable[testName] - 1 === retriesTimes
const noRetries = retriesTimes === 0
debug('no retries %o last attempt %o', noRetries, lastAttempt)
if (noRetries || lastAttempt) {
const filepath = writeFailedTestInfo(info)
debug('saving the log file %s', filepath)
info.filepath = filepath
}
cy.task('failed', info, { log: false })
}
// We have to do a hack to make sure OUR "afterEach" callback function
// runs BEFORE any user supplied "afterEach" callback.
// Otherwise commands executed by the user callback might
// add too many commands to the log, making post-mortem
// triage very difficult. In this case we just wrap client supplied
// "afterEach" function with our callback "onFailed". This ensures we run
// first.
const _afterEach = afterEach
/* eslint-disable-next-line no-global-assign */
afterEach = (name, fn) => {
// eslint-disable-line
if (typeof name === 'function') {
fn = name
name = fn.name
}
// run our "onFailed" before running the client function "fn"
_afterEach(name, function () {
// run callbacks with context "this"
onFailed.call(this)
fn.call(this)
})
}
startLogging()
beforeEach(initLog)
// register our callback to process failed tests without wrapping
_afterEach(onFailed)