@revoloo/cypress6
Version:
Cypress.io end to end testing tool
372 lines (284 loc) • 11.6 kB
JavaScript
// See: ./errorScenarios.md for details about error messages and stack traces
const _ = require('lodash')
const path = require('path')
const errorStackParser = require('error-stack-parser')
const { codeFrameColumns } = require('@babel/code-frame')
const $utils = require('./utils')
const $sourceMapUtils = require('./source_map_utils')
const { getStackLines, replacedStack, stackWithoutMessage, splitStack, unsplitStack } = require('@packages/server/lib/util/stack_utils')
const whitespaceRegex = /^(\s*)*/
const stackLineRegex = /^\s*(at )?.*@?\(?.*\:\d+\:\d+\)?$/
const customProtocolRegex = /^[^:\/]+:\/+/
const percentNotEncodedRegex = /%(?![0-9A-F][0-9A-F])/g
const STACK_REPLACEMENT_MARKER = '__stackReplacementMarker'
const hasCrossFrameStacks = (specWindow) => {
// get rid of the top lines since they naturally have different line:column
const normalize = (stack) => {
return stack.replace(/^.*\n/, '')
}
const topStack = normalize((new Error()).stack)
const specStack = normalize((new specWindow.Error()).stack)
return topStack === specStack
}
const stackWithContentAppended = (err, stack) => {
const appendToStack = err.appendToStack
if (!appendToStack || !appendToStack.content) return stack
delete err.appendToStack
// if the content is a stack trace, which is should be, then normalize the
// indentation, then indent it a little further than the rest of the stack
const normalizedContent = normalizeStackIndentation(appendToStack.content)
const content = $utils.indent(normalizedContent, 2)
return `${stack}\n\n${appendToStack.title}:\n${content}`
}
const stackWithLinesRemoved = (stack, cb) => {
const [messageLines, stackLines] = splitStack(stack)
const remainingStackLines = cb(stackLines)
return unsplitStack(messageLines, remainingStackLines)
}
const stackWithLinesDroppedFromMarker = (stack, marker, includeLast = false) => {
return stackWithLinesRemoved(stack, (lines) => {
// drop lines above the marker
const withAboveMarkerRemoved = _.dropWhile(lines, (line) => {
return !_.includes(line, marker)
})
return includeLast ? withAboveMarkerRemoved : withAboveMarkerRemoved.slice(1)
})
}
const stackWithReplacementMarkerLineRemoved = (stack) => {
return stackWithLinesRemoved(stack, (lines) => {
return _.reject(lines, (line) => _.includes(line, STACK_REPLACEMENT_MARKER))
})
}
const stackWithUserInvocationStackSpliced = (err, userInvocationStack) => {
const stack = _.trim(err.stack, '\n') // trim newlines from end
const [messageLines, stackLines] = splitStack(stack)
const userInvocationStackWithoutMessage = stackWithoutMessage(userInvocationStack)
let commandCallIndex = _.findIndex(stackLines, (line) => {
return line.includes(STACK_REPLACEMENT_MARKER)
})
if (commandCallIndex < 0) {
commandCallIndex = stackLines.length
}
stackLines.splice(commandCallIndex, stackLines.length, 'From Your Spec Code:')
stackLines.push(userInvocationStackWithoutMessage)
// the commandCallIndex is based off the stack without the message,
// but the full stack includes the message + 'From Your Spec Code:',
// so we adjust it
return {
stack: unsplitStack(messageLines, stackLines),
index: commandCallIndex + messageLines.length + 1,
}
}
const getLanguageFromExtension = (filePath) => {
return (path.extname(filePath) || '').toLowerCase().replace('.', '') || null
}
const getCodeFrameFromSource = (sourceCode, { line, column, relativeFile, absoluteFile }) => {
if (!sourceCode) return
const frame = codeFrameColumns(sourceCode, { start: { line, column } })
if (!frame) return
return {
line,
column,
originalFile: relativeFile,
relativeFile,
absoluteFile,
frame,
language: getLanguageFromExtension(relativeFile),
}
}
const captureUserInvocationStack = (ErrorConstructor, userInvocationStack) => {
if (!userInvocationStack) {
const newErr = new ErrorConstructor('userInvocationStack')
// if browser natively supports Error.captureStackTrace, use it (chrome) (must be bound)
// otherwise use our polyfill on top.Error
const captureStackTrace = ErrorConstructor.captureStackTrace ? ErrorConstructor.captureStackTrace.bind(ErrorConstructor) : Error.captureStackTrace
captureStackTrace(newErr, captureUserInvocationStack)
userInvocationStack = newErr.stack
}
userInvocationStack = normalizedUserInvocationStack(userInvocationStack)
return userInvocationStack
}
const getCodeFrameStackLine = (err, stackIndex) => {
// if a specific index is not specified, use the first line with a file in it
if (stackIndex == null) return _.find(err.parsedStack, (line) => !!line.fileUrl)
return err.parsedStack[stackIndex]
}
const getCodeFrame = (err, stackIndex) => {
if (err.codeFrame) return err.codeFrame
const stackLine = getCodeFrameStackLine(err, stackIndex)
if (!stackLine) return
const { fileUrl, originalFile } = stackLine
return getCodeFrameFromSource($sourceMapUtils.getSourceContents(fileUrl, originalFile), stackLine)
}
const getWhitespace = (line) => {
if (!line) return ''
const [, whitespace] = line.match(whitespaceRegex) || []
return whitespace || ''
}
const decodeSpecialChars = (filePath) => {
// the source map will encode certain characters like spaces and emojis
// but characters like &%#^% are not encoded
// because % is not encoded we must encode it manually before trying to decode
// or else decodeURIComponent will throw an error
//
// however if a filename has something like %20 in it we have no way of telling
// if that's the actual filename or an encoded space so we'll assume that its encoded
// since that's far more likely and to fix this issue
// we would have to patch the source-map library which likely isn't worth it
if (filePath) {
return decodeURIComponent(filePath.replace(percentNotEncodedRegex, '%25'))
}
return filePath
}
const getSourceDetails = (generatedDetails) => {
const sourceDetails = $sourceMapUtils.getSourcePosition(generatedDetails.file, generatedDetails)
if (!sourceDetails) return generatedDetails
const { line, column, file } = sourceDetails
let fn = generatedDetails.function
return {
line,
column,
file: decodeSpecialChars(file),
function: fn,
}
}
const functionExtrasRegex = /(\/<|<\/<)$/
const cleanFunctionName = (functionName) => {
if (!_.isString(functionName)) return '<unknown>'
return _.trim(functionName.replace(functionExtrasRegex, ''))
}
const parseLine = (line) => {
const isStackLine = stackLineRegex.test(line)
if (!isStackLine) return
const parsed = errorStackParser.parse({ stack: line })[0]
if (!parsed) return
return {
line: parsed.lineNumber,
column: parsed.columnNumber,
file: parsed.fileName,
function: cleanFunctionName(parsed.functionName),
}
}
const stripCustomProtocol = (filePath) => {
if (!filePath) {
return
}
// if the file path (after all said and done)
// still starts with "http://" or "https://" then
// it is an URL and we have no idea how it maps
// to a physical file location on disk. Let it be.
const httpProtocolRegex = /^https?:\/\//
if (httpProtocolRegex.test(filePath)) {
return
}
return filePath.replace(customProtocolRegex, '')
}
const getSourceDetailsForLine = (projectRoot, line) => {
const whitespace = getWhitespace(line)
const generatedDetails = parseLine(line)
// if it couldn't be parsed, it's a message line
if (!generatedDetails) {
return {
message: line,
whitespace,
}
}
const sourceDetails = getSourceDetails(generatedDetails)
const originalFile = sourceDetails.file
const relativeFile = stripCustomProtocol(originalFile)
return {
function: sourceDetails.function,
fileUrl: generatedDetails.file,
originalFile,
relativeFile,
absoluteFile: relativeFile ? path.join(projectRoot, relativeFile) : undefined,
line: sourceDetails.line,
// adding 1 to column makes more sense for code frame and opening in editor
column: sourceDetails.column + 1,
whitespace,
}
}
const getSourceDetailsForFirstLine = (stack, projectRoot) => {
const line = getStackLines(stack)[0]
if (!line) return
return getSourceDetailsForLine(projectRoot, line)
}
const reconstructStack = (parsedStack) => {
return _.map(parsedStack, (parsedLine) => {
if (parsedLine.message != null) {
return `${parsedLine.whitespace}${parsedLine.message}`
}
const { whitespace, originalFile, function: fn, line, column } = parsedLine
return `${whitespace}at ${fn} (${originalFile || '<unknown>'}:${line}:${column})`
}).join('\n')
}
const getSourceStack = (stack, projectRoot) => {
if (!_.isString(stack)) return {}
const getSourceDetailsWithStackUtil = _.partial(getSourceDetailsForLine, projectRoot)
const parsed = _.map(stack.split('\n'), getSourceDetailsWithStackUtil)
return {
parsed,
sourceMapped: reconstructStack(parsed),
}
}
const normalizeStackIndentation = (stack) => {
const [messageLines, stackLines] = splitStack(stack)
const normalizedStackLines = _.map(stackLines, (line) => {
if (stackLineRegex.test(line)) {
// stack lines get indented 4 spaces
return line.replace(whitespaceRegex, ' ')
}
// message lines don't get indented at all
return line.replace(whitespaceRegex, '')
})
return unsplitStack(messageLines, normalizedStackLines)
}
const normalizedStack = (err) => {
// Firefox errors do not include the name/message in the stack, whereas
// Chromium-based errors do, so we normalize them so that the stack
// always includes the name/message
const errString = err.toString()
const errStack = err.stack || ''
// the stack has already been normalized and normalizing the indentation
// again could mess up the whitespace
if (errStack.includes(errString)) return err.stack
const firstErrLine = errString.slice(0, errString.indexOf('\n'))
const firstStackLine = errStack.slice(0, errStack.indexOf('\n'))
const stackIncludesMsg = firstStackLine.includes(firstErrLine)
if (!stackIncludesMsg) {
return `${errString}\n${errStack}`
}
return normalizeStackIndentation(errStack)
}
const normalizedUserInvocationStack = (userInvocationStack) => {
// Firefox user invocation stack includes a line at the top that looks like
// addCommand/cy[name]@cypress:///../driver/src/cypress/cy.js:936:77 or
// add/$Chainer.prototype[key] (cypress:///../driver/src/cypress/chainer.js:30:128)
// whereas Chromium browsers have the user's line first
const stackLines = getStackLines(userInvocationStack)
const winnowedStackLines = _.reject(stackLines, (line) => {
// WARNING: STACK TRACE WILL BE DIFFERENT IN DEVELOPMENT vs PRODUCTOIN
// stacks in development builds look like:
// at cypressErr (cypress:///../driver/src/cypress/error_utils.js:259:17)
// stacks in prod builds look like:
// at cypressErr (http://localhost:3500/isolated-runner/cypress_runner.js:173123:17)
return line.includes('cy[name]') || line.includes('Chainer.prototype[key]')
}).join('\n')
return normalizeStackIndentation(winnowedStackLines)
}
module.exports = {
getCodeFrame,
getSourceStack,
getStackLines,
getSourceDetailsForFirstLine,
hasCrossFrameStacks,
normalizedStack,
normalizedUserInvocationStack,
replacedStack,
stackWithContentAppended,
stackWithLinesDroppedFromMarker,
stackWithoutMessage,
stackWithReplacementMarkerLineRemoved,
stackWithUserInvocationStackSpliced,
captureUserInvocationStack,
}