diff --git a/.gitignore b/.gitignore
index f6254eba4094..b7b9623797f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,10 @@ Cached Theme Material Design.pak
packages/desktop-gui/cypress/videos
packages/desktop-gui/src/jsconfig.json
+# from driver
+packages/driver/test/cypress/videos
+packages/driver/test/cypress/screenshots
+
# from example
packages/example/app
packages/example/build
diff --git a/package.json b/package.json
index fac1b149f9a2..5e60e600e7ea 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"@types/mini-css-extract-plugin": "0.8.0",
"@types/mocha": "5.2.7",
"@types/node": "12.12.21",
+ "@types/prismjs": "1.16.0",
"@types/ramda": "0.25.47",
"@types/react": "^16.9.27",
"@types/react-dom": "16.9.4",
diff --git a/packages/desktop-gui/cypress/integration/settings_spec.js b/packages/desktop-gui/cypress/integration/settings_spec.js
index 0cb5452bfa49..85eaf91711cd 100644
--- a/packages/desktop-gui/cypress/integration/settings_spec.js
+++ b/packages/desktop-gui/cypress/integration/settings_spec.js
@@ -385,7 +385,7 @@ describe('Settings', () => {
})
it('opens ci guide when learn more is clicked', () => {
- cy.get('.settings-record-key').contains('Learn More').click().then(function () {
+ cy.get('.settings-record-key').contains('Learn more').click().then(function () {
expect(this.ipc.externalOpen).to.be.calledWith('https://on.cypress.io/what-is-a-record-key')
})
})
@@ -752,6 +752,79 @@ describe('Settings', () => {
})
})
+ describe('file preference panel', () => {
+ const availableEditors = [
+ { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' },
+ { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' },
+ { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' },
+ { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' },
+ { id: 'other', name: 'Other', isOther: true, openerId: '' },
+ ]
+
+ beforeEach(function () {
+ this.getUserEditor = this.util.deferred()
+ cy.stub(this.ipc, 'getUserEditor').returns(this.getUserEditor.promise)
+ cy.stub(this.ipc, 'setUserEditor').resolves()
+
+ this.openProject.resolve(this.config)
+ this.projectStatuses[0].id = this.config.projectId
+ this.getProjectStatus.resolve(this.projectStatuses[0])
+
+ this.goToSettings()
+
+ cy.contains('File Opener Preference').click()
+ })
+
+ it('displays file preference section', () => {
+ cy.contains('Your preference is used to open files')
+ })
+
+ it('opens file preference guide when learn more is clicked', () => {
+ cy.get('.file-preference').contains('Learn more').click().then(function () {
+ expect(this.ipc.externalOpen).to.be.calledWith('https://on.cypress.io/file-opener-preference')
+ })
+ })
+
+ it('loads preferred editor and available editors', function () {
+ expect(this.ipc.getUserEditor).to.be.called
+ })
+
+ it('shows spinner', () => {
+ cy.get('.loading-editors')
+ })
+
+ describe('when editors load with preferred editor', () => {
+ beforeEach(function () {
+ this.getUserEditor.resolve({ availableEditors, preferredOpener: availableEditors[3] })
+ })
+
+ it('displays available editors with preferred one selected', () => {
+ cy.get('.loading-editors').should('not.exist')
+ cy.contains('Atom')
+ cy.contains('Other')
+ cy.contains('Visual Studio Code').closest('li').should('have.class', 'is-selected')
+ })
+
+ it('sets editor through ipc when a different editor is selected', function () {
+ cy.contains('Atom').click()
+ .closest('li').should('have.class', 'is-selected')
+
+ cy.wrap(this.ipc.setUserEditor).should('be.calledWith', availableEditors[0])
+ })
+ })
+
+ describe('when editors load without preferred editor', () => {
+ beforeEach(function () {
+ this.getUserEditor.resolve({ availableEditors })
+ })
+
+ it('does not select an editor', () => {
+ cy.get('.loading-editors').should('not.exist')
+ cy.get('.editor-picker li').should('not.have.class', 'is-selected')
+ })
+ })
+ })
+
describe('errors', () => {
const errorText = 'An unexpected error occurred'
diff --git a/packages/desktop-gui/src/app/nav.scss b/packages/desktop-gui/src/app/nav.scss
index 7d1f39ef72c8..e04f1b7ab3cf 100644
--- a/packages/desktop-gui/src/app/nav.scss
+++ b/packages/desktop-gui/src/app/nav.scss
@@ -246,14 +246,6 @@
margin-right: 4px;
}
-.browser-beta {
- font-size: 12px;
- top: -5px;
- position: relative;
- margin-left: 4px;
- color: #d87b0b;
-}
-
.browser-info-tooltip {
background: #ececec;
border-color: #c7c7c7;
@@ -269,6 +261,14 @@
}
}
+.dropdown .browser-beta {
+ font-size: 12px;
+ top: -5px;
+ position: relative;
+ margin-left: 4px;
+ color: #d8a10b;
+}
+
.close-browser {
.btn {
padding: 6px 9px;
diff --git a/packages/desktop-gui/src/lib/ipc.js b/packages/desktop-gui/src/lib/ipc.js
index 98389c3caea8..8ada670135f6 100644
--- a/packages/desktop-gui/src/lib/ipc.js
+++ b/packages/desktop-gui/src/lib/ipc.js
@@ -44,6 +44,8 @@ register('get:project:statuses')
register('get:project:status')
register('get:record:keys')
register('get:specs', false)
+register('get:user:editor')
+register('set:user:editor')
register('launch:browser', false)
register('log:out')
register('on:focus:tests', false)
diff --git a/packages/desktop-gui/src/main.scss b/packages/desktop-gui/src/main.scss
index edd50e3a0bf7..0f1ccdf611c1 100644
--- a/packages/desktop-gui/src/main.scss
+++ b/packages/desktop-gui/src/main.scss
@@ -4,3 +4,4 @@
@import 'styles/components/*';
@import '!(styles)*/**/*';
@import '../../ui-components/src/dropdown';
+@import '../../ui-components/src/editor-picker';
diff --git a/packages/desktop-gui/src/settings/file-preference.jsx b/packages/desktop-gui/src/settings/file-preference.jsx
new file mode 100644
index 000000000000..4b3472c3fe95
--- /dev/null
+++ b/packages/desktop-gui/src/settings/file-preference.jsx
@@ -0,0 +1,71 @@
+import _ from 'lodash'
+import { action } from 'mobx'
+import { EditorPicker } from '@packages/ui-components'
+import { observer, useLocalStore } from 'mobx-react'
+import React, { useEffect } from 'react'
+
+import ipc from '../lib/ipc'
+
+const openHelp = (e) => {
+ e.preventDefault()
+ ipc.externalOpen('https://on.cypress.io/file-opener-preference')
+}
+
+const save = _.debounce((editor) => {
+ ipc.setUserEditor(editor)
+ .catch(() => {}) // ignore errors
+}, 500)
+
+const FilePreference = observer(() => {
+ const state = useLocalStore(() => ({
+ editors: [],
+ isLoadingEditor: true,
+ chosenEditor: {},
+ setEditors: action((editors) => {
+ state.editors = editors
+ state.isLoadingEditor = false
+ }),
+ setChosenEditor: action((editor) => {
+ state.chosenEditor = editor
+ save(editor)
+ }),
+ setOtherPath: action((otherPath) => {
+ const otherOption = _.find(state.editors, { isOther: true })
+
+ otherOption.openerId = otherPath
+ save(otherOption)
+ }),
+ }))
+
+ useEffect(() => {
+ ipc.getUserEditor().then(({ preferredOpener, availableEditors }) => {
+ if (preferredOpener) {
+ state.setChosenEditor(preferredOpener)
+ }
+
+ state.setEditors(availableEditors)
+ })
+ }, [true])
+
+ return (
+
+
+ Learn more
+
+
Your preference is used to open files from the Test Runner (e.g. when clicking links in error stack traces)
+ {state.isLoadingEditor ?
+
+ Loading Editors...
+
:
+
+ }
+
+ )
+})
+
+export default FilePreference
diff --git a/packages/desktop-gui/src/settings/record-key.jsx b/packages/desktop-gui/src/settings/record-key.jsx
index 4a1e750acce8..5be22a6365cb 100644
--- a/packages/desktop-gui/src/settings/record-key.jsx
+++ b/packages/desktop-gui/src/settings/record-key.jsx
@@ -74,8 +74,7 @@ class RecordKey extends Component {
return (
- {' '}
- Learn More
+ Learn more
A Record Key sends your failing tests, screenshots, and videos to your{' '}
diff --git a/packages/desktop-gui/src/settings/settings.jsx b/packages/desktop-gui/src/settings/settings.jsx
index b111f35367e6..6415ab776776 100644
--- a/packages/desktop-gui/src/settings/settings.jsx
+++ b/packages/desktop-gui/src/settings/settings.jsx
@@ -9,6 +9,7 @@ import ProjectId from './project-id'
import RecordKey from './record-key'
import ProxySettings from './proxy-settings'
import NodeVersion from './node-version'
+import FilePreference from './file-preference'
import Experiments from './experiments'
import { getExperiments, experimental } from '@packages/server/lib/experiments'
@@ -45,10 +46,13 @@ const Settings = observer(({ project, app }) => {
- {hasExperiments &&
-
-
+
+
+ {hasExperiments &&
+
+
+
}
diff --git a/packages/desktop-gui/src/settings/settings.scss b/packages/desktop-gui/src/settings/settings.scss
index a9934393f0d4..428789e2d035 100644
--- a/packages/desktop-gui/src/settings/settings.scss
+++ b/packages/desktop-gui/src/settings/settings.scss
@@ -245,6 +245,20 @@
}
}
+.editor-picker {
+ margin: 0;
+ padding: 0;
+
+ li {
+ list-style: none;
+ }
+
+ label {
+ margin: 0;
+ font-weight: normal;
+ }
+}
+
.experiment-intro {
padding-bottom: 15px;
margin-bottom: 0px;
diff --git a/packages/desktop-gui/webpack.config.ts b/packages/desktop-gui/webpack.config.ts
index 1a4beb59830c..de73af77c700 100644
--- a/packages/desktop-gui/webpack.config.ts
+++ b/packages/desktop-gui/webpack.config.ts
@@ -1,8 +1,10 @@
-import commonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
+import getCommonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
import path from 'path'
+import webpack from 'webpack'
-const config: typeof commonConfig = {
- ...commonConfig,
+// @ts-ignore
+const config: webpack.Configuration = {
+ ...getCommonConfig(),
entry: {
app: [require.resolve('@babel/polyfill'), path.resolve(__dirname, 'src/main')],
},
@@ -26,7 +28,10 @@ config.plugins = [
config.resolve = {
...config.resolve,
alias: {
+ 'bluebird': require.resolve('bluebird'),
'lodash$': require.resolve('lodash'),
+ 'mobx': require.resolve('mobx'),
+ 'mobx-react': require.resolve('mobx-react'),
'react': require.resolve('react'),
'react-dom': require.resolve('react-dom'),
},
diff --git a/packages/driver/package.json b/packages/driver/package.json
index ef7728ffce0c..1b738037413e 100644
--- a/packages/driver/package.json
+++ b/packages/driver/package.json
@@ -10,6 +10,7 @@
"start": "$(yarn bin coffee) test/support/server.coffee"
},
"devDependencies": {
+ "@babel/code-frame": "^7.0.0",
"@cypress/bower-kendo-ui": "0.0.2",
"@cypress/sinon-chai": "1.1.0",
"@cypress/underscore.inflection": "1.0.1",
@@ -37,6 +38,7 @@
"cors": "2.8.5",
"cypress-multi-reporters": "1.2.4",
"debug": "4.1.1",
+ "error-stack-parser": "2.0.6",
"errorhandler": "1.5.1",
"eventemitter2": "4.1.2",
"express": "4.16.4",
@@ -60,6 +62,7 @@
"react-dom-16.0.0": "npm:react-dom@16.0.0",
"setimmediate": "1.0.5",
"sinon": "8.1.1",
+ "source-map": "0.7.3",
"text-mask-addons": "3.8.0",
"underscore": "1.9.1",
"underscore.string": "3.3.5",
diff --git a/packages/driver/src/cy/assertions.js b/packages/driver/src/cy/assertions.js
index d18dacbb8257..658a7a476204 100644
--- a/packages/driver/src/cy/assertions.js
+++ b/packages/driver/src/cy/assertions.js
@@ -111,6 +111,10 @@ const create = function (state, queue, retryFn) {
// them up with existing ones
cmd.set('assertionIndex', 0)
+ if (state('current') != null) {
+ state('current').set('currentAssertionCommand', cmd)
+ }
+
return cmd.get('fn').originalFn.apply(
state('ctx'),
[subject].concat(cmd.get('args')),
@@ -202,6 +206,8 @@ const create = function (state, queue, retryFn) {
err = e2
}
+ err.isDefaultAssertionErr = isDefaultAssertionErr
+
options.error = err
if (err.retry === false) {
diff --git a/packages/driver/src/cy/chai.js b/packages/driver/src/cy/chai.js
index ba6bca9ce2d1..b78ed33faba1 100644
--- a/packages/driver/src/cy/chai.js
+++ b/packages/driver/src/cy/chai.js
@@ -9,6 +9,7 @@ const sinonChai = require('@cypress/sinon-chai')
const $dom = require('../dom')
const $utils = require('../cypress/utils')
const $errUtils = require('../cypress/error_utils')
+const $stackUtils = require('../cypress/stack_utils')
const $chaiJquery = require('../cypress/chai_jquery')
const chaiInspect = require('./chai/inspect')
@@ -198,8 +199,8 @@ chai.use((chai, u) => {
}
}
- const overrideChaiAsserts = function (specWindow, assertFn) {
- chai.Assertion.prototype.assert = createPatchedAssert(specWindow, assertFn)
+ const overrideChaiAsserts = function (specWindow, state, assertFn) {
+ chai.Assertion.prototype.assert = createPatchedAssert(specWindow, state, assertFn)
const _origGetmessage = function (obj, args) {
const negate = chaiUtils.flag(obj, 'negate')
@@ -414,7 +415,29 @@ chai.use((chai, u) => {
})
}
- const createPatchedAssert = (specWindow, assertFn) => {
+ const captureUserInvocationStack = (specWindow, state, ssfi) => {
+ let userInvocationStack
+
+ // we need a user invocation stack with the top line being the point where
+ // the error occurred for the sake of the code frame
+ // in chrome, stack lines from another frame don't appear in the
+ // error. specWindow.Error works for our purposes because it
+ // doesn't include anything extra (chai.Assertion error doesn't work
+ // because it doesn't have lines from the spec iframe)
+ // in firefox, specWindow.Error has too many extra lines at the
+ // beginning, but chai.AssertionError helps us winnow those down
+ if ($stackUtils.hasCrossFrameStacks(specWindow)) {
+ userInvocationStack = (new chai.AssertionError('uis', {}, ssfi)).stack
+ } else {
+ userInvocationStack = (new specWindow.Error()).stack
+ }
+
+ userInvocationStack = $stackUtils.normalizedUserInvocationStack(userInvocationStack)
+
+ state('currentAssertionUserInvocationStack', userInvocationStack)
+ }
+
+ const createPatchedAssert = (specWindow, state, assertFn) => {
return (function (...args) {
let err
const passed = chaiUtils.test(this, args)
@@ -438,39 +461,44 @@ chai.use((chai, u) => {
if (!err) return
- // stack from chai AssertionError instances are useless, because
- // the chai code is served from `top`, which binds to `top`'s Error
- // but assertions fail inside the spec window and then err.stack
- // will not include the frames from the spec window (a different window)
- // for security purposes. therefore, we instantiate a new error on
- // the spec window to get a better stack
- const betterStackErr = new specWindow.Error(err.message)
-
- err.stack = $errUtils.replacedStack(err, betterStackErr)
+ // when assert() is used instead of expect(), we override the method itself
+ // below in `overrideAssert` and prefer the user invocation stack
+ // that we capture there
+ if (!state('assertUsed')) {
+ captureUserInvocationStack(specWindow, state, chaiUtils.flag(this, 'ssfi'))
+ }
throw err
})
}
- const overrideExpect = () => {
+ const overrideExpect = (specWindow, state) => {
// only override assertions for this specific
// expect function instance so we do not affect
// the outside world
return (val, message) => {
+ captureUserInvocationStack(specWindow, state, overrideExpect)
+
// make the assertion
return new chai.Assertion(val, message)
}
}
- const overrideAssert = function () {
+ const overrideAssert = function (specWindow, state) {
const fn = (express, errmsg) => {
+ state('assertUsed', true)
+ captureUserInvocationStack(specWindow, state, fn)
+
return chai.assert(express, errmsg)
}
const fns = _.functions(chai.assert)
_.each(fns, (name) => {
- return fn[name] = function () {
+ fn[name] = function () {
+ state('assertUsed', true)
+ captureUserInvocationStack(specWindow, state, overrideAssert)
+
return chai.assert[name].apply(this, arguments)
}
})
@@ -478,9 +506,9 @@ chai.use((chai, u) => {
return fn
}
- const setSpecWindowGlobals = function (specWindow, assertFn) {
- const expect = overrideExpect()
- const assert = overrideAssert()
+ const setSpecWindowGlobals = function (specWindow, state) {
+ const expect = overrideExpect(specWindow, state)
+ const assert = overrideAssert(specWindow, state)
specWindow.chai = chai
specWindow.expect = expect
@@ -493,15 +521,14 @@ chai.use((chai, u) => {
}
}
- const create = function (specWindow, assertFn) {
- // restoreOverrides()
+ const create = function (specWindow, state, assertFn) {
restoreAsserts()
overrideChaiInspect()
overrideChaiObjDisplay()
- overrideChaiAsserts(specWindow, assertFn)
+ overrideChaiAsserts(specWindow, state, assertFn)
- return setSpecWindowGlobals(specWindow)
+ return setSpecWindowGlobals(specWindow, state)
}
module.exports = {
@@ -511,8 +538,6 @@ chai.use((chai, u) => {
setSpecWindowGlobals,
- // overrideChai: overrideChai
-
restoreAsserts,
overrideExpect,
diff --git a/packages/driver/src/cy/commands/asserting.js b/packages/driver/src/cy/commands/asserting.js
index 754a03c5b085..2db2de5e2ac1 100644
--- a/packages/driver/src/cy/commands/asserting.js
+++ b/packages/driver/src/cy/commands/asserting.js
@@ -8,14 +8,20 @@ const reExistence = /exist/
const reEventually = /^eventually/
const reHaveLength = /length/
-module.exports = function (Commands, Cypress, cy) {
+module.exports = function (Commands, Cypress, cy, state) {
const shouldFnWithCallback = function (subject, fn) {
+ state('current')?.set('followedByShouldCallback', true)
+
return Promise
.try(() => {
const remoteSubject = cy.getRemotejQueryInstance(subject)
return fn.call(this, remoteSubject ? remoteSubject : subject)
- }).return(subject)
+ })
+ .tap(() => {
+ state('current')?.set('followedByShouldCallback', false)
+ })
+ .return(subject)
}
const shouldFn = function (subject, chainers, ...args) {
diff --git a/packages/driver/src/cy/commands/commands.js b/packages/driver/src/cy/commands/commands.js
index 2f40438e1085..23ebdbb98471 100644
--- a/packages/driver/src/cy/commands/commands.js
+++ b/packages/driver/src/cy/commands/commands.js
@@ -17,7 +17,8 @@ const command = function (ctx, name, ...args) {
module.exports = function (Commands, Cypress, cy) {
Commands.addChainer({
- command (chainer, args) {
+ // userInvocationStack has to be passed in here, but can be ignored
+ command (chainer, userInvocationStack, args) {
return command(chainer, ...args)
},
})
diff --git a/packages/driver/src/cy/commands/cookies.js b/packages/driver/src/cy/commands/cookies.js
index b16b1fbbe89d..fb8850789ca3 100644
--- a/packages/driver/src/cy/commands/cookies.js
+++ b/packages/driver/src/cy/commands/cookies.js
@@ -124,10 +124,15 @@ module.exports = function (Commands, Cypress, cy, state, config) {
action,
cmd: command,
browserDisplayName: Cypress.browser.displayName,
- errMessage: err.message,
- errStack: err.stack,
+ error: err,
},
onFail,
+ errProps: {
+ appendToStack: {
+ title: 'From Node.js Internals',
+ content: err.stack,
+ },
+ },
})
}
}
diff --git a/packages/driver/src/cy/commands/files.js b/packages/driver/src/cy/commands/files.js
index cda87d0c1f96..75fb740801f5 100644
--- a/packages/driver/src/cy/commands/files.js
+++ b/packages/driver/src/cy/commands/files.js
@@ -2,7 +2,6 @@ const _ = require('lodash')
const Promise = require('bluebird')
const $errUtils = require('../../cypress/error_utils')
-const $errMessages = require('../../cypress/error_messages')
module.exports = (Commands, Cypress, cy) => {
Commands.addAll({
@@ -62,11 +61,11 @@ module.exports = (Commands, Cypress, cy) => {
return
}
- const { message, docsUrl } = (contents != null)
- // file exists but it shouldn't
- ? $errUtils.errObjByPath($errMessages, 'files.existent', { file, filePath })
- // file doesn't exist but it should
- : $errUtils.errObjByPath($errMessages, 'files.nonexistent', { file, filePath })
+ // file exists but it shouldn't - or - file doesn't exist but it should
+ const errPath = contents ? 'files.existent' : 'files.nonexistent'
+ const { message, docsUrl } = $errUtils.cypressErrByPath(errPath, {
+ args: { file, filePath },
+ })
err.message = message
err.docsUrl = docsUrl
diff --git a/packages/driver/src/cy/commands/navigation.js b/packages/driver/src/cy/commands/navigation.js
index 80b53a4eebd7..efdca3d0a809 100644
--- a/packages/driver/src/cy/commands/navigation.js
+++ b/packages/driver/src/cy/commands/navigation.js
@@ -771,8 +771,13 @@ module.exports = (Commands, Cypress, cy, state, config) => {
// the onLoad callback should only be skipped if specified
if (runOnLoadCallback !== false) {
- if (options.onLoad != null) {
- options.onLoad.call(runnable.ctx, win)
+ try {
+ options.onLoad?.call(runnable.ctx, win)
+ } catch (err) {
+ // mark these as onLoad errors, so they're treated differently
+ // than Node.js errors when caught below
+ err.isOnLoadError = true
+ throw err
}
}
@@ -981,6 +986,15 @@ module.exports = (Commands, Cypress, cy, state, config) => {
args,
})
})
+
+ return
+ }
+
+ // if it came from the user's onLoad callback, it's not a network
+ // failure, and we should just throw the original error
+ if (err.isOnLoadError) {
+ delete err.isOnLoadError
+ throw err
}
visitFailedByErr(err, url, () => {
@@ -989,9 +1003,13 @@ module.exports = (Commands, Cypress, cy, state, config) => {
args: {
url,
error: err,
- stack: err.stack,
},
- noStackTrace: true,
+ errProps: {
+ appendToStack: {
+ title: 'From Node.js Internals',
+ content: err.stack,
+ },
+ },
})
})
})
diff --git a/packages/driver/src/cy/commands/request.js b/packages/driver/src/cy/commands/request.js
index a368b0aad3c1..17abb809f4ea 100644
--- a/packages/driver/src/cy/commands/request.js
+++ b/packages/driver/src/cy/commands/request.js
@@ -306,11 +306,15 @@ module.exports = (Commands, Cypress, cy, state, config) => {
onFail: options._log,
args: {
error: err.message,
- stack: err.stack,
method: requestOpts.method,
url: requestOpts.url,
},
- noStackTrace: true,
+ errProps: {
+ appendToStack: {
+ title: 'From Node.js Internals',
+ content: err.stack,
+ },
+ },
})
})
},
diff --git a/packages/driver/src/cy/commands/task.js b/packages/driver/src/cy/commands/task.js
index f8ab36fa5fbc..10ebf2d135e2 100644
--- a/packages/driver/src/cy/commands/task.js
+++ b/packages/driver/src/cy/commands/task.js
@@ -3,6 +3,7 @@ const Promise = require('bluebird')
const $utils = require('../../cypress/utils')
const $errUtils = require('../../cypress/error_utils')
+const $stackUtils = require('../../cypress/stack_utils')
module.exports = (Commands, Cypress, cy) => {
Commands.addAll({
@@ -72,24 +73,24 @@ module.exports = (Commands, Cypress, cy) => {
args: { task, timeout: options.timeout, error: error.message },
})
})
- .catch((error) => {
+ .catch((err) => {
// re-throw if timedOut error from above
- if (error.name === 'CypressError') {
- throw error
+ if ($errUtils.isCypressErr(err)) {
+ throw err
}
- $errUtils.normalizeErrorStack(error)
+ err.stack = $stackUtils.normalizedStack(err)
- if (error?.isKnownError) {
+ if (err?.isKnownError) {
$errUtils.throwErrByPath('task.known_error', {
onFail: options._log,
- args: { task, error: error.message },
+ args: { task, error: err.message },
})
}
$errUtils.throwErrByPath('task.failed', {
onFail: options._log,
- args: { task, error: error?.stack || error?.message || error },
+ args: { task, error: err?.stack || err?.message || err },
})
})
},
diff --git a/packages/driver/src/cy/commands/waiting.js b/packages/driver/src/cy/commands/waiting.js
index 4b42383083eb..507820623d6e 100644
--- a/packages/driver/src/cy/commands/waiting.js
+++ b/packages/driver/src/cy/commands/waiting.js
@@ -70,12 +70,12 @@ module.exports = (Commands, Cypress, cy, state) => {
return Promise.resolve(xhr)
}
- options.error = $errUtils.errMsgByPath('wait.timed_out', {
+ options.error = $errUtils.errByPath('wait.timed_out', {
timeout: options.timeout,
alias,
num,
type,
- })
+ }).message
const args = [alias, type, index, num, options]
diff --git a/packages/driver/src/cy/commands/xhr.js b/packages/driver/src/cy/commands/xhr.js
index 579e1cab9cf1..d98f25f22f06 100644
--- a/packages/driver/src/cy/commands/xhr.js
+++ b/packages/driver/src/cy/commands/xhr.js
@@ -4,6 +4,7 @@ const Promise = require('bluebird')
const $utils = require('../../cypress/utils')
const $errUtils = require('../../cypress/error_utils')
+const $stackUtils = require('../../cypress/stack_utils')
const $Server = require('../../cypress/server')
const $Location = require('../../cypress/location')
@@ -221,10 +222,10 @@ const startXhrServer = (cy, state, config) => {
onXhrAbort: (xhr, stack) => {
setResponse(state, xhr)
- const err = new Error($errUtils.errMsgByPath('xhr.aborted'))
+ const err = $errUtils.errByPath('xhr.aborted')
err.name = 'AbortError'
- err.stack = stack
+ err.stack = $stackUtils.replacedStack(err, stack)
const log = logs[xhr.id]
diff --git a/packages/driver/src/cy/errors.js b/packages/driver/src/cy/errors.js
index d94ad17ced39..0c22ef21c286 100644
--- a/packages/driver/src/cy/errors.js
+++ b/packages/driver/src/cy/errors.js
@@ -1,7 +1,5 @@
-const _ = require('lodash')
const $dom = require('../dom')
const $errUtils = require('../cypress/error_utils')
-const $errorMessages = require('../cypress/error_messages')
const crossOriginScriptRe = /^script error/i
@@ -35,58 +33,34 @@ const create = (state, config, log) => {
}
const createUncaughtException = (type, args) => {
- // @ts-ignore
let [msg, source, lineno, colno, err] = args // eslint-disable-line no-unused-vars
-
- const current = state('current')
+ let message
+ let docsUrl
// reset the msg on a cross origin script error
// since no details are accessible
if (crossOriginScriptRe.test(msg)) {
- msg = $errUtils.errMsgByPath('uncaught.cross_origin_script')
- }
-
- const createErrFromMsg = () => {
- return new Error($errUtils.errMsgByPath('uncaught.error', {
- msg, source, lineno,
- }))
- }
-
- // if we have the 5th argument it means we're in a super
- // modern browser making this super simple to work with.
- err = err ?? createErrFromMsg()
+ const crossOriginErr = $errUtils.errByPath('uncaught.cross_origin_script')
- let uncaughtErrLookup = ''
-
- if (type === 'app') {
- uncaughtErrLookup = 'uncaught.fromApp'
- } else if (type === 'spec') {
- uncaughtErrLookup = 'uncaught.fromSpec'
+ message = crossOriginErr.message
+ docsUrl = crossOriginErr.docsUrl
}
- const uncaughtErrObj = $errUtils.errObjByPath($errorMessages, uncaughtErrLookup)
-
- const uncaughtErrProps = $errUtils.modifyErrMsg(err, uncaughtErrObj.message, (msg1, msg2) => {
- return `${msg1}\n\n${msg2}`
+ // if we have the 5th argument it means we're in a modern browser with an
+ // error object already provided. otherwise, we create one
+ err = err ?? $errUtils.errByPath('uncaught.error', {
+ message, source, lineno,
})
- _.defaults(uncaughtErrProps, uncaughtErrObj)
-
- const uncaughtErr = $errUtils.mergeErrProps(err, uncaughtErrProps)
+ err.docsUrl = docsUrl
- $errUtils.modifyErrName(err, `Uncaught ${err.name}`)
+ const uncaughtErr = $errUtils.createUncaughtException(type, err)
+ const current = state('current')
uncaughtErr.onFail = () => {
- const l = current && current.getLastLog()
-
- if (l) {
- return l.error(uncaughtErr)
- }
+ current?.getLastLog()?.error(uncaughtErr)
}
- // normalize error message for firefox
- $errUtils.normalizeErrorStack(uncaughtErr)
-
return uncaughtErr
}
diff --git a/packages/driver/src/cy/retries.js b/packages/driver/src/cy/retries.js
index b1805721d12d..b80e1e258e19 100644
--- a/packages/driver/src/cy/retries.js
+++ b/packages/driver/src/cy/retries.js
@@ -1,6 +1,5 @@
const _ = require('lodash')
const Promise = require('bluebird')
-const debug = require('debug')('cypress:driver:retries')
const $errUtils = require('../cypress/error_utils')
@@ -36,14 +35,6 @@ const create = (Cypress, state, timeout, clearTimeout, whenStable, finishAsserti
let { error } = options
- // TODO: remove this once the codeframe PR is in since that
- // correctly handles not rewrapping errors so that stack
- // traces are correctly displayed
- if (debug.enabled && error && !$errUtils.CypressErrorRe.test(error.name)) {
- debug('retrying due to caught error...')
- console.error(error) // eslint-disable-line no-console
- }
-
const interval = options.interval ?? options._interval
// we calculate the total time we've been retrying
@@ -75,7 +66,7 @@ const create = (Cypress, state, timeout, clearTimeout, whenStable, finishAsserti
({ error, onFail } = options)
- const prependMsg = $errUtils.errMsgByPath('miscellaneous.retry_timed_out')
+ const prependMsg = $errUtils.errByPath('miscellaneous.retry_timed_out').message
const retryErrProps = $errUtils.modifyErrMsg(error, prependMsg, (msg1, msg2) => {
return `${msg2}${msg1}`
diff --git a/packages/driver/src/cy/timeouts.js b/packages/driver/src/cy/timeouts.js
index e35a20ac5a6e..84068108ac22 100644
--- a/packages/driver/src/cy/timeouts.js
+++ b/packages/driver/src/cy/timeouts.js
@@ -1,3 +1,4 @@
+const _ = require('lodash')
const $errUtils = require('../cypress/error_utils')
const create = (state) => {
@@ -9,7 +10,7 @@ const create = (state) => {
$errUtils.throwErrByPath('miscellaneous.outside_test')
}
- if (ms) {
+ if (_.isFinite(ms)) {
// if delta is true then we add (or subtract) from the
// runnables current timeout instead of blanketingly setting it
ms = delta ? runnable.timeout() + ms : ms
diff --git a/packages/driver/src/cypress.js b/packages/driver/src/cypress.js
index ad0c2161f026..0125a9c88412 100644
--- a/packages/driver/src/cypress.js
+++ b/packages/driver/src/cypress.js
@@ -30,6 +30,7 @@ const $Screenshot = require('./cypress/screenshot')
const $SelectorPlayground = require('./cypress/selector_playground')
const $utils = require('./cypress/utils')
const $errUtils = require('./cypress/error_utils')
+const $scriptUtils = require('./cypress/script_utils')
const browserInfo = require('./cypress/browser')
const debug = require('debug')('cypress:driver:cypress')
@@ -90,6 +91,8 @@ class $Cypress {
this.runner = null
this.Commands = null
this._RESUMED_AT_TEST = null
+ this.$autIframe = null
+ this.onSpecReady = null
this.events = $Events.extend(this)
@@ -169,8 +172,9 @@ class $Cypress {
return this.action('cypress:config', config)
}
- initialize ($autIframe) {
- return this.cy.initialize($autIframe)
+ initialize ({ $autIframe, onSpecReady }) {
+ this.$autIframe = $autIframe
+ this.onSpecReady = onSpecReady
}
run (fn) {
@@ -186,7 +190,7 @@ class $Cypress {
// specs or support files have been downloaded
// or parsed. we have not received any custom commands
// at this point
- onSpecWindow (specWindow) {
+ onSpecWindow (specWindow, scripts) {
const logFn = (...args) => {
return this.log.apply(this, args)
}
@@ -206,7 +210,17 @@ class $Cypress {
$FirefoxForcedGc.install(this)
- return null
+ $scriptUtils.runScripts(specWindow, scripts)
+ .catch((err) => {
+ err = $errUtils.createUncaughtException('spec', err)
+
+ this.runner.onScriptError(err)
+ })
+ .then(() => {
+ this.cy.initialize(this.$autIframe)
+
+ this.onSpecReady()
+ })
}
action (eventName, ...args) {
diff --git a/packages/driver/src/cypress/chainer.js b/packages/driver/src/cypress/chainer.js
index 48a064e89efb..7de6c7563d65 100644
--- a/packages/driver/src/cypress/chainer.js
+++ b/packages/driver/src/cypress/chainer.js
@@ -1,7 +1,10 @@
const _ = require('lodash')
+const $stackUtils = require('./stack_utils')
class $Chainer {
- constructor () {
+ constructor (userInvocationStack, specWindow) {
+ this.userInvocationStack = userInvocationStack
+ this.specWindow = specWindow
this.chainerId = _.uniqueId('chainer')
this.firstCall = true
}
@@ -12,9 +15,15 @@ class $Chainer {
static add (key, fn) {
$Chainer.prototype[key] = function (...args) {
+ const userInvocationStack = this.useInitialStack
+ ? this.userInvocationStack
+ : $stackUtils.normalizedUserInvocationStack(
+ (new this.specWindow.Error('command invocation stack')).stack,
+ )
+
// call back the original function with our new args
// pass args an as array and not a destructured invocation
- if (fn(this, args)) {
+ if (fn(this, userInvocationStack, args)) {
// no longer the first call
this.firstCall = false
}
@@ -26,12 +35,21 @@ class $Chainer {
}
// creates a new chainer instance
- static create (key, args) {
- const chainer = new $Chainer()
+ static create (key, userInvocationStack, specWindow, args) {
+ const chainer = new $Chainer(userInvocationStack, specWindow)
+
+ // this is the first command chained off of cy, so we use
+ // the stack passed in from that call instead of the stack
+ // from this invocation
+ chainer.useInitialStack = true
// since this is the first function invocation
// we need to pass through onto our instance methods
- return chainer[key].apply(chainer, args)
+ const chain = chainer[key].apply(chainer, args)
+
+ chain.useInitialStack = false
+
+ return chain
}
}
diff --git a/packages/driver/src/cypress/cy.js b/packages/driver/src/cypress/cy.js
index 453f62b9fb3b..4ced1b5cb4f2 100644
--- a/packages/driver/src/cypress/cy.js
+++ b/packages/driver/src/cypress/cy.js
@@ -6,6 +6,7 @@ const Promise = require('bluebird')
const $dom = require('../dom')
const $utils = require('./utils')
const $errUtils = require('./error_utils')
+const $stackUtils = require('./stack_utils')
const $Chai = require('../cy/chai')
const $Xhrs = require('../cy/xhrs')
const $jQuery = require('../cy/jquery')
@@ -60,6 +61,10 @@ const setRemoteIframeProps = ($autIframe, state) => {
return state('$autIframe', $autIframe)
}
+function __stackReplacementMarker (fn, ctx, args) {
+ return fn.apply(ctx, args)
+}
+
// We only set top.onerror once since we make it configurable:false
// but we update cy instance every run (page reload or rerun button)
let curCy = null
@@ -133,7 +138,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
const mouse = $Mouse.create(state, keyboard, focused, Cypress)
const timers = $Timers.create()
- const { expect } = $Chai.create(specWindow, assertions.assert)
+ const { expect } = $Chai.create(specWindow, state, assertions.assert)
const xhrs = $Xhrs.create(state)
const aliases = $Aliases.create(state)
@@ -345,7 +350,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
// run the command's fn with runnable's context
try {
- ret = command.get('fn').apply(state('ctx'), args)
+ ret = __stackReplacementMarker(command.get('fn'), state('ctx'), args)
} catch (err) {
throw err
} finally {
@@ -556,14 +561,12 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
}
state('resolve', resolve)
-
- return state('reject', rejectOuterAndCancelInner)
+ state('reject', rejectOuterAndCancelInner)
})
.catch((err) => {
// since this failed this means that a
// specific command failed and we should
// highlight it in red or insert a new command
-
err.name = err.name || 'CypressError'
errors.commandRunningFailed(err)
@@ -673,12 +676,53 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
return state('index', queue.length)
}
- const fail = function (err, runnable) {
+ const getUserInvocationStack = (err) => {
+ const current = state('current')
+ const currentAssertionCommand = current?.get('currentAssertionCommand')
+ const withInvocationStack = currentAssertionCommand || current
+ // user assertion errors (expect().to, etc) get their invocation stack
+ // attached to the error thrown from chai
+ // command errors and command assertion errors (default assertion or cy.should)
+ // have the invocation stack attached to the current command
+ let userInvocationStack = state('currentAssertionUserInvocationStack')
+
+ // if there is no user invocation stack from an assertion or it is the default
+ // assertion, meaning it came from a command (e.g. cy.get), prefer the
+ // command's user invocation stack so the code frame points to the command.
+ // `should` callbacks are tricky because the `currentAssertionUserInvocationStack`
+ // points to the `cy.should`, but the error came from inside the callback,
+ // so we need to prefer that.
+ if (
+ !userInvocationStack
+ || err.isDefaultAssertionErr
+ || (currentAssertionCommand && !current?.get('followedByShouldCallback'))
+ ) {
+ userInvocationStack = withInvocationStack?.get('userInvocationStack')
+ }
+
+ if (!userInvocationStack) return
+
+ if (
+ $errUtils.isCypressErr(err)
+ || $errUtils.isAssertionErr(err)
+ || $errUtils.isChaiValidationErr(err)
+ ) {
+ return userInvocationStack
+ }
+ }
+
+ const fail = (err) => {
let rets
stopped = true
- err = $errUtils.normalizeErrorStack(err)
+ err.stack = $stackUtils.normalizedStack(err)
+
+ err = $errUtils.enhanceStack({
+ err,
+ userInvocationStack: getUserInvocationStack(err),
+ projectRoot: config('projectRoot'),
+ })
err = $errUtils.processErr(err, config)
@@ -718,11 +762,11 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
try {
// collect all of the callbacks for 'fail'
rets = Cypress.action('cy:fail', err, state('runnable'))
- } catch (err2) {
- $errUtils.normalizeErrorStack(err2)
-
+ } catch (cyFailErr) {
// and if any of these throw synchronously immediately error
- return finish(err2)
+ cyFailErr.stack = $stackUtils.normalizedStack(cyFailErr)
+
+ return finish(cyFailErr)
}
// bail if we had callbacks attached
@@ -962,13 +1006,17 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
}
cy[name] = function (...args) {
+ const userInvocationStack = $stackUtils.normalizedUserInvocationStack(
+ (new specWindow.Error('command invocation stack')).stack,
+ )
+
let ret
ensures.ensureRunnable(name)
// this is the first call on cypress
// so create a new chainer instance
- const chain = $Chainer.create(name, args)
+ const chain = $Chainer.create(name, userInvocationStack, specWindow, args)
// store the chain so we can access it later
state('chain', chain)
@@ -1009,7 +1057,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
return chain
}
- return cy.addChainer(name, (chainer, args) => {
+ return cy.addChainer(name, (chainer, userInvocationStack, args) => {
const { firstCall, chainerId } = chainer
// dont enqueue / inject any new commands if
@@ -1027,6 +1075,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
args,
type,
chainerId,
+ userInvocationStack,
fn: wrap(firstCall),
})
@@ -1123,30 +1172,22 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
onSpecWindowUncaughtException () {
// create the special uncaught exception err
- let runnable
const err = errors.createUncaughtException('spec', arguments)
+ const runnable = state('runnable')
- runnable = state('runnable')
-
- if (runnable) {
- // we're using an explicit done callback here
- let d; let r
-
- d = state('done')
+ if (!runnable) return err
- if (d) {
- d(err)
- }
-
- r = state('reject')
+ try {
+ fail(err)
+ } catch (failErr) {
+ const r = state('reject')
if (r) {
return r(err)
}
- }
- // else pass the error along
- return err
+ return failErr
+ }
},
onUncaughtException () {
@@ -1254,7 +1295,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
state('done', done)
}
- let ret = fn.apply(this, arguments)
+ let ret = __stackReplacementMarker(fn, this, arguments)
// if we returned a value from fn
// and enqueued some new commands
@@ -1317,13 +1358,10 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
// else just return ret
return ret
- } catch (error) {
- // if our runnable.fn throw synchronously
- // then it didnt fail from a cypress command
- // but we should still teardown and handle
+ } catch (err) {
+ // if runnable.fn threw synchronously, then it didnt fail from
+ // a cypress command, but we should still teardown and handle
// the error
- const err = error
-
return fail(err, runnable)
}
}
diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee
index d720409b032f..42938350f186 100644
--- a/packages/driver/src/cypress/error_messages.coffee
+++ b/packages/driver/src/cypress/error_messages.coffee
@@ -214,8 +214,9 @@ module.exports = {
backend_error: (obj) -> {
message: """
#{cmd('{{cmd}}')} had an unexpected error {{action}} {{browserDisplayName}}.
- {{errMessage}}
- {{errStack}}
+
+ > {{error}}
+
"""
docsUrl: "https://on.cypress.io/#{_.toLower(obj.cmd)}"
}
@@ -493,7 +494,7 @@ module.exports = {
If you want to assert on the property's value, then switch to use #{cmd('its')} and add an assertion such as:
`cy.wrap({ foo: 'bar' }).its('foo').should('eq', 'bar')`
- """
+ """
docsUrl: "https://on.cypress.io/invoke"
}
subject_null_or_undefined: {
@@ -503,7 +504,7 @@ module.exports = {
If you expect your subject to be `{{value}}`, then add an assertion such as:
`cy.wrap({{value}}).should('be.{{value}}')`
- """
+ """
docsUrl: "https://on.cypress.io/invoke"
}
null_or_undefined_prop_value: {
@@ -515,7 +516,7 @@ module.exports = {
If you expect the property `{{prop}}` to be `{{value}}`, then switch to use #{cmd('its')} and add an assertion such as:
`cy.wrap({ foo: {{value}} }).its('foo').should('be.{{value}}')`
- """
+ """
docsUrl: "https://on.cypress.io/invoke"
}
@@ -527,7 +528,7 @@ module.exports = {
If you expect your subject to be `{{value}}`, then add an assertion such as:
`cy.wrap({{value}}).should('be.{{value}}')`
- """
+ """
docsUrl: "https://on.cypress.io/its"
}
null_or_undefined_prop_value: {
@@ -539,7 +540,7 @@ module.exports = {
If you expect the property `{{prop}}` to be `{{value}}`, then add an assertion such as:
`cy.wrap({ foo: {{value}} }).its('foo').should('be.{{value}}')`
- """
+ """
docsUrl: "https://on.cypress.io/its"
}
@@ -553,7 +554,7 @@ module.exports = {
If you do not expect the property `{{prop}}` to exist, then add an assertion such as:
`cy.wrap({ foo: 'bar' }).its('quux').should('not.exist')`
- """
+ """
docsUrl: "https://on.cypress.io/{{cmd}}"
}
previous_prop_null_or_undefined: {
@@ -630,7 +631,7 @@ module.exports = {
To rewrite this custom command you'd likely write:
`Cypress.Commands.add(#{obj.signature})`
- """
+ """
docsUrl: "https://on.cypress.io/custom-command-interface-changed"
}
returned_value_and_commands_from_custom_command: (obj) -> {
@@ -922,10 +923,6 @@ module.exports = {
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
- The stack trace for this error is:
-
- #{obj.stack}
"""
docsUrl: "https://on.cypress.io/request"
}
@@ -1170,14 +1167,6 @@ module.exports = {
unavailable: "The XHR server is unavailable or missing. This should never happen and likely is a bug. Open an issue if you see this message."
setCookie:
- backend_error: {
- message: """
- #{cmd('setCookie')} had an unexpected error setting the requested cookie in {{browserDisplayName}}.
-
- {{errStack}}
- """
- docsUrl: "https://on.cypress.io/setcookie"
- }
invalid_arguments: {
message: "#{cmd('setCookie')} must be passed two string arguments for `name` and `value`."
docsUrl: "https://on.cypress.io/setcookie"
@@ -1428,19 +1417,20 @@ module.exports = {
}
uncaught:
- cross_origin_script: """
- Script error.
-
- Cypress detected that an uncaught error was thrown from a cross origin script.
+ cross_origin_script: {
+ message: """
+ Script error.
- We cannot provide you the stack trace, line number, or file where this error occurred.
+ Cypress detected that an uncaught error was thrown from a cross origin script.
- Check your Developer Tools Console for the actual error - it should be printed there.
+ We cannot provide you the stack trace, line number, or file where this error occurred.
- It's possible to enable debugging these scripts by adding the `crossorigin` attribute and setting a CORS header.
+ Check your Developer Tools Console for the actual error - it should be printed there.
- https://on.cypress.io/cross-origin-script-error
- """
+ It's possible to enable debugging these scripts by adding the `crossorigin` attribute and setting a CORS header.
+ """
+ docsUrl: "https://on.cypress.io/cross-origin-script-error"
+ }
error_in_hook: (obj) ->
msg = "Because this error occurred during a `#{obj.hookName}` hook we are skipping "
@@ -1450,15 +1440,15 @@ module.exports = {
msg += "all of the remaining tests."
msg
-
error: (obj) ->
- {msg, source, lineno} = obj
-
- msg + if source and lineno then " (#{source}:#{lineno})" else ""
+ {message, source, lineno} = obj
+ message + if source and lineno then " (#{source}:#{lineno})" else ""
fromApp: {
message: """
- This error originated from your application code, not from Cypress.
+ The following error originated from your application code, not from Cypress.
+
+ > {{errMsg}}
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -1468,7 +1458,9 @@ module.exports = {
}
fromSpec:
message: """
- This error originated from your test code, not from Cypress.
+ The following error originated from your test code, not from Cypress.
+
+ > {{errMsg}}
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
"""
@@ -1574,10 +1566,6 @@ module.exports = {
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
- The stack trace for this error is:
-
- {{stack}}
"""
loading_file_failed: (obj) ->
"""
@@ -1630,15 +1618,14 @@ module.exports = {
#{cmd('request')} will automatically get and set cookies and enable you to parse responses.
"""
- specify_file_by_relative_path: """
- #{cmd('visit')} failed because the 'file://...' protocol is not supported by Cypress.
-
- To visit a local file, you can pass in the relative path to the file from the `projectRoot` (Note: if the configuration value `baseUrl` is set, the supplied path will be resolved from the `baseUrl` instead of `projectRoot`)
-
- https://docs.cypress.io/api/commands/visit.html
+ specify_file_by_relative_path: {
+ message: """
+ #{cmd('visit')} failed because the 'file://...' protocol is not supported by Cypress.
- https://docs.cypress.io/api/cypress-api/config.html
+ To visit a local file, you can pass in the relative path to the file from the `projectRoot` (Note: if the configuration value `baseUrl` is set, the supplied path will be resolved from the `baseUrl` instead of `projectRoot`)
"""
+ docsUrl: ["https://docs.cypress.io/api/commands/visit.html", "/https://docs.cypress.io/api/cypress-api/config.html"]
+ }
wait:
alias_invalid: {
diff --git a/packages/driver/src/cypress/error_utils.js b/packages/driver/src/cypress/error_utils.js
index 5b355fc2ddc5..6130a7125c76 100644
--- a/packages/driver/src/cypress/error_utils.js
+++ b/packages/driver/src/cypress/error_utils.js
@@ -2,11 +2,17 @@ const _ = require('lodash')
const $errorMessages = require('./error_messages')
const $utils = require('./utils')
+const $stackUtils = require('./stack_utils')
-const ERROR_PROPS = 'message type name stack sourceMappedStack parsedStack fileName lineNumber columnNumber host uncaught actual expected showDiff isPending docsUrl'.split(' ')
+const ERROR_PROPS = 'message type name stack sourceMappedStack parsedStack fileName lineNumber columnNumber host uncaught actual expected showDiff isPending docsUrl codeFrame'.split(' ')
-const CypressErrorRe = /(AssertionError|CypressError)/
-const twoOrMoreNewLinesRe = /\n{2,}/
+if (!Error.captureStackTrace) {
+ Error.captureStackTrace = (err, fn) => {
+ const stack = (new Error()).stack
+
+ err.stack = $stackUtils.stackWithLinesDroppedFromMarker(stack, fn.name)
+ }
+}
const wrapErr = (err) => {
if (!err) return
@@ -14,78 +20,74 @@ const wrapErr = (err) => {
return $utils.reduceProps(err, ERROR_PROPS)
}
-const mergeErrProps = (origErr, ...newProps) => {
- return _.extend(origErr, ...newProps)
+const isAssertionErr = (err = {}) => {
+ return err.name === 'AssertionError'
}
-const replaceNameInStack = (err, newName) => {
- const { name, stack } = err
+const isChaiValidationErr = (err = {}) => {
+ return _.startsWith(err.message, 'Invalid Chai property')
+}
- if (!stack) return stack
+const isCypressErr = (err = {}) => {
+ return err.name === 'CypressError'
+}
- return stack.replace(name, newName)
+const mergeErrProps = (origErr, ...newProps) => {
+ return _.extend(origErr, ...newProps)
}
-const modifyErrName = (err, newName) => {
- const newStack = replaceNameInStack(err, newName)
+const stackWithReplacedProps = (err, props) => {
+ const {
+ message: originalMessage,
+ name: originalName,
+ stack: originalStack,
+ } = err
- err.name = newName
- err.stack = newStack
+ const {
+ message: newMessage,
+ name: newName,
+ } = props
- return err
-}
+ // if stack doesn't already exist, leave it as is
+ if (!originalStack) return originalStack
+
+ let stack
-const replaceMsgInStack = (err, newMsg) => {
- const { message, name, stack } = err
+ if (newMessage) {
+ stack = originalStack.replace(originalMessage, newMessage)
+ }
- if (!stack) return stack
+ if (newName) {
+ stack = originalStack.replace(originalName, newName)
+ }
- if (message) {
- // reset stack by replacing the original message with the new one
- return stack.replace(message, newMsg)
+ if (originalMessage) {
+ return stack
}
// if message is undefined or an empty string, the error (in Chrome at least)
// is 'Error\n\n' and it results in wrongly prepending the
// new message, looking like 'Error\n\n'
- return stack.replace(name, `${name}: ${newMsg}`)
-}
-
-const newLineAtBeginningRe = /^\n+/
+ const message = newMessage || err.message
+ const name = newName || err.name
-const replacedStack = (err, newStackErr) => {
- // if err already lacks a stack or we've removed the stack
- // for some reason, keep it stackless
- if (!err.stack) return err.stack
-
- const errString = err.toString()
-
- const newStackErrString = newStackErr.toString()
- const stackLines = newStackErr.stack
- .replace(newStackErrString, '')
- .replace(newLineAtBeginningRe, '')
-
- // sometimes the new stack doesn't include any lines, so just stick
- // with the original stack
- if (!stackLines) return err.stack
-
- return `${errString}\n${stackLines}`
+ return originalStack.replace(originalName, `${name}: ${message}`)
}
const modifyErrMsg = (err, newErrMsg, cb) => {
- err = normalizeErrorStack(err)
+ err.stack = $stackUtils.normalizedStack(err)
- const newMsg = cb(err.message, newErrMsg)
- const newStack = replaceMsgInStack(err, newMsg)
+ const newMessage = cb(err.message, newErrMsg)
+ const newStack = stackWithReplacedProps(err, { message: newMessage })
- err.message = newMsg
+ err.message = newMessage
err.stack = newStack
return err
}
-const appendErrMsg = (err, messageOrObj) => {
- return modifyErrMsg(err, messageOrObj, (msg1, msg2) => {
+const appendErrMsg = (err, errMsg) => {
+ return modifyErrMsg(err, errMsg, (msg1, msg2) => {
// we don't want to just throw in extra
// new lines if there isn't even a msg
if (!msg1) return msg2
@@ -116,10 +118,6 @@ const throwErr = (err, options = {}) => {
err = cypressErr({ message: err })
}
- if (options.noStackTrace) {
- err.stack = ''
- }
-
let { onFail, errProps } = options
// assume onFail is a command if
@@ -146,19 +144,18 @@ const throwErr = (err, options = {}) => {
}
const throwErrByPath = (errPath, options = {}) => {
- let err
+ const err = errByPath(errPath, options.args)
- try {
- err = cypressErrByPath(errPath, options)
- } catch (internalError) {
- err = internalErr(internalError)
+ // gets rid of internal stack lines that just build the error
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(err, throwErrByPath)
}
return throwErr(err, options)
}
const warnByPath = (errPath, options = {}) => {
- const errObj = errObjByPath($errorMessages, errPath, options.args)
+ const errObj = errByPath(errPath, options.args)
let err = errObj.message
if (errObj.docsUrl) {
@@ -168,174 +165,169 @@ const warnByPath = (errPath, options = {}) => {
$utils.warning(err)
}
+class InternalCypressError extends Error {
+ constructor (message) {
+ super(message)
+
+ this.name = 'InternalCypressError'
+
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, InternalCypressError)
+ }
+ }
+}
+
+class CypressError extends Error {
+ constructor (message) {
+ super(message)
+
+ this.name = 'CypressError'
+
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, CypressError)
+ }
+ }
+}
+
const internalErr = (err) => {
- const newErr = new Error(err)
+ const newErr = new InternalCypressError(err.message)
- return mergeErrProps(newErr, err, { name: 'InternalError' })
+ return mergeErrProps(newErr, err)
}
const cypressErr = (err) => {
- const newErr = new Error(err.message)
+ const newErr = new CypressError(err.message)
- return mergeErrProps(newErr, err, { name: 'CypressError' })
+ return mergeErrProps(newErr, err)
}
const cypressErrByPath = (errPath, options = {}) => {
- const errObj = errObjByPath($errorMessages, errPath, options.args)
+ const errObj = errByPath(errPath, options.args)
return cypressErr(errObj)
}
-const normalizeMsgNewLines = (message) => {
- //# normalize more than 2 new lines
- //# into only exactly 2 new lines
- return _
- .chain(message)
- .split(twoOrMoreNewLinesRe)
- .compact()
- .join('\n\n')
- .value()
-}
-
const replaceErrMsgTokens = (errMessage, args) => {
if (!errMessage) return errMessage
+ const replace = (str, argValue, argKey) => {
+ return str.replace(new RegExp(`\{\{${argKey}\}\}`, 'g'), argValue)
+ }
+
const getMsg = function (args = {}) {
return _.reduce(args, (message, argValue, argKey) => {
- return message.replace(new RegExp(`\{\{${argKey}\}\}`, 'g'), argValue)
+ if (_.isArray(message)) {
+ return _.map(message, (str) => replace(str, argValue, argKey))
+ }
+
+ return replace(message, argValue, argKey)
}, errMessage)
}
- return normalizeMsgNewLines(getMsg(args))
+ // replace more than 2 newlines with exactly 2 newlines
+ return $utils.normalizeNewLines(getMsg(args), 2)
}
-const errObjByPath = (errLookupObj, errPath, args) => {
- let errObjStrOrFn = getObjValueByPath(errLookupObj, errPath)
+const errByPath = (msgPath, args) => {
+ let msgValue = _.get($errorMessages, msgPath)
- if (!errObjStrOrFn) {
- throw new Error(`Error message path '${errPath}' does not exist`)
+ if (!msgValue) {
+ return internalErr({ message: `Error message path '${msgPath}' does not exist` })
}
- let errObj = errObjStrOrFn
+ let msgObj = msgValue
- if (_.isFunction(errObjStrOrFn)) {
- errObj = errObjStrOrFn(args)
+ if (_.isFunction(msgValue)) {
+ msgObj = msgValue(args)
}
- if (_.isString(errObj)) {
- // normalize into object if given string
- errObj = {
- message: errObj,
+ if (_.isString(msgObj)) {
+ msgObj = {
+ message: msgObj,
}
}
- let extendErrObj = {
- message: replaceErrMsgTokens(errObj.message, args),
- }
-
- if (errObj.docsUrl) {
- extendErrObj.docsUrl = replaceErrMsgTokens(errObj.docsUrl, args)
- }
-
- return _.extend({}, errObj, extendErrObj)
-}
-
-const errMsgByPath = (errPath, args) => {
- return getErrMsgWithObjByPath($errorMessages, errPath, args)
+ return cypressErr({
+ message: replaceErrMsgTokens(msgObj.message, args),
+ docsUrl: msgObj.docsUrl ? replaceErrMsgTokens(msgObj.docsUrl, args) : undefined,
+ })
}
-const getErrMsgWithObjByPath = (errLookupObj, errPath, args) => {
- const errObj = errObjByPath(errLookupObj, errPath, args)
-
- return errObj.message
-}
+const createUncaughtException = (type, err) => {
+ const errPath = type === 'spec' ? 'uncaught.fromSpec' : 'uncaught.fromApp'
+ let uncaughtErr = errByPath(errPath, {
+ errMsg: err.message,
+ })
-const getErrMessage = (err) => {
- if (err && err.displayMessage) {
- return err.displayMessage
- }
+ modifyErrMsg(err, uncaughtErr.message, () => uncaughtErr.message)
- if (err && err.message) {
- return err.message
- }
+ err.docsUrl = _.compact([uncaughtErr.docsUrl, err.docsUrl])
return err
}
-const getErrStack = (err) => {
- // if cypress or assertion error
- // don't return the stack
- if (CypressErrorRe.test(err.name)) {
- return err.toString()
+// stacks from command failures and assertion failures have the right message
+// but the stack points to cypress internals. here we replace the internal
+// cypress stack with the invocation stack, which points to the user's code
+const stackAndCodeFrameIndex = (err, userInvocationStack) => {
+ if (!userInvocationStack) return { stack: err.stack }
+
+ if (isCypressErr(err) || isChaiValidationErr(err)) {
+ return $stackUtils.stackWithUserInvocationStackSpliced(err, userInvocationStack)
}
- return err.stack
+ return { stack: $stackUtils.replacedStack(err, userInvocationStack) }
}
-const normalizeErrorStack = (err) => {
- // normalize error message + stack for firefox
- const errString = err.toString()
- const errStack = err.stack || ''
+const preferredStackAndCodeFrameIndex = (err, userInvocationStack) => {
+ let { stack, index } = stackAndCodeFrameIndex(err, userInvocationStack)
- if (!errStack.slice(0, errStack.indexOf('\n')).includes(errString.slice(0, errString.indexOf('\n')))) {
- err.stack = `${errString}\n${errStack}`
- }
+ stack = $stackUtils.stackWithContentAppended(err, stack)
+ stack = $stackUtils.stackWithReplacementMarkerLineRemoved(stack)
- return err
+ return { stack, index }
}
-const getObjValueByPath = (obj, keyPath) => {
- if (!_.isObject(obj)) {
- throw new Error('The first parameter to utils.getObjValueByPath() must be an object')
- }
-
- if (!_.isString(keyPath)) {
- throw new Error('The second parameter to utils.getObjValueByPath() must be a string')
- }
-
- const keys = keyPath.split('.')
- let val = obj
+const enhanceStack = ({ err, userInvocationStack, projectRoot }) => {
+ const { stack, index } = preferredStackAndCodeFrameIndex(err, userInvocationStack)
+ const { sourceMapped, parsed } = $stackUtils.getSourceStack(stack, projectRoot)
- for (let key of keys) {
- val = val[key]
- if (!val) {
- break
- }
- }
+ err.stack = stack
+ err.sourceMappedStack = sourceMapped
+ err.parsedStack = parsed
+ err.codeFrame = $stackUtils.getCodeFrame(err, index)
- return val
+ return err
}
-//// all errors flow through this function before they're finally thrown
-//// or used to reject promises
+// all errors flow through this function before they're finally thrown
+// or used to reject promises
const processErr = (errObj = {}, config) => {
- if (config('isInteractive') || !errObj.docsUrl) {
+ let docsUrl = errObj.docsUrl
+
+ if (config('isInteractive') || !docsUrl) {
return errObj
}
+ docsUrl = _(docsUrl).castArray().compact().join('\n\n')
+
// append the docs url when not interactive so it appears in the stdout
- return appendErrMsg(errObj, errObj.docsUrl)
+ return appendErrMsg(errObj, docsUrl)
}
module.exports = {
appendErrMsg,
+ createUncaughtException,
cypressErr,
cypressErrByPath,
- CypressErrorRe,
- errMsgByPath,
- errObjByPath,
- getErrMessage,
- getErrMsgWithObjByPath,
- getErrStack,
- getObjValueByPath,
- internalErr,
+ enhanceStack,
+ errByPath,
+ isAssertionErr,
+ isChaiValidationErr,
+ isCypressErr,
makeErrFromObj,
mergeErrProps,
modifyErrMsg,
- modifyErrName,
- normalizeErrorStack,
- normalizeMsgNewLines,
- replacedStack,
processErr,
throwErr,
throwErrByPath,
diff --git a/packages/driver/src/cypress/log.js b/packages/driver/src/cypress/log.js
index 3e6669f027b0..2e3145024a0e 100644
--- a/packages/driver/src/cypress/log.js
+++ b/packages/driver/src/cypress/log.js
@@ -359,13 +359,7 @@ const Log = function (cy, state, config, obj) {
},
getError (err) {
- // dont log stack traces on cypress errors
- // or assertion errors
- if ($errUtils.CypressErrorRe.test(err.name)) {
- return err.toString()
- }
-
- return err.stack
+ return err.stack || err.message
},
setElAttrs () {
diff --git a/packages/driver/src/cypress/mocha.js b/packages/driver/src/cypress/mocha.js
index 0cce18ffa233..b1db2068bf50 100644
--- a/packages/driver/src/cypress/mocha.js
+++ b/packages/driver/src/cypress/mocha.js
@@ -114,7 +114,7 @@ const patchRunnerFail = () => {
const errMessage = _.get(err, 'message')
if (errMessage && errMessage.indexOf('Resolution method is overspecified') > -1) {
- err.message = $errUtils.errMsgByPath('mocha.overspecified', { error: err.stack })
+ err.message = $errUtils.errByPath('mocha.overspecified', { error: err.stack }).message
}
// if this isnt a correct error object then just bail
@@ -171,9 +171,9 @@ const patchRunnableResetTimeout = () => {
}
this.timer = setTimeout(() => {
- const errMessage = $errUtils.errMsgByPath(getErrPath(), { ms })
+ const err = $errUtils.errByPath(getErrPath(), { ms })
- runnable.callback(new Error(errMessage))
+ runnable.callback(err)
runnable.timedOut = true
}, ms)
}
diff --git a/packages/driver/src/cypress/network_utils.js b/packages/driver/src/cypress/network_utils.js
new file mode 100644
index 000000000000..3df7c9d59c06
--- /dev/null
+++ b/packages/driver/src/cypress/network_utils.js
@@ -0,0 +1,22 @@
+const Promise = require('bluebird')
+
+const fetch = (resourceUrl, win = window) => {
+ return new Promise((resolve, reject) => {
+ const xhr = new win.XMLHttpRequest()
+
+ xhr.onload = function () {
+ resolve(this.responseText)
+ }
+
+ xhr.onerror = function () {
+ reject(new Error(`Fetching resource at '${resourceUrl}' failed`))
+ }
+
+ xhr.open('GET', resourceUrl)
+ xhr.send()
+ })
+}
+
+module.exports = {
+ fetch,
+}
diff --git a/packages/driver/src/cypress/runner.js b/packages/driver/src/cypress/runner.js
index 98e6b9da690b..e3b86e714002 100644
--- a/packages/driver/src/cypress/runner.js
+++ b/packages/driver/src/cypress/runner.js
@@ -8,6 +8,7 @@ const Pending = require('mocha/lib/pending')
const $Log = require('./log')
const $utils = require('./utils')
const $errUtils = require('./error_utils')
+const $stackUtils = require('./stack_utils')
const mochaCtxKeysRe = /^(_runnable|test)$/
const betweenQuotesRe = /\"(.+?)\"/
@@ -731,7 +732,7 @@ const _runnerListeners = function (_runner, Cypress, _emissions, getTestById, ge
let hookName
const isHook = runnable.type === 'hook'
- $errUtils.normalizeErrorStack(err)
+ err.stack = $stackUtils.normalizedStack(err)
if (isHook) {
const parentTitle = runnable.parent.title
@@ -742,10 +743,10 @@ const _runnerListeners = function (_runner, Cypress, _emissions, getTestById, ge
// we're skipping the remaining tests in this suite
err = $errUtils.appendErrMsg(
err,
- $errUtils.errMsgByPath('uncaught.error_in_hook', {
+ $errUtils.errByPath('uncaught.error_in_hook', {
parentTitle,
hookName,
- }),
+ }).message,
)
}
@@ -786,34 +787,29 @@ const create = function (specWindow, mocha, Cypress, cy) {
_runner.suite = mocha.getRootSuite()
- specWindow.onerror = function () {
- let err = cy.onSpecWindowUncaughtException.apply(cy, arguments)
-
+ const onScriptError = (err) => {
// err will not be returned if cy can associate this
// uncaught exception to an existing runnable
if (!err) {
return true
}
- const todoMsg = function () {
+ const todoMsg = () => {
if (!Cypress.config('isTextTerminal')) {
return 'Check your console for the stack trace or click this message to see where it originated from.'
}
}
- const append = () => {
- return _.chain([
- 'Cypress could not associate this error to any specific test.',
- 'We dynamically generated a new test to display this failure.',
- todoMsg(),
- ])
- .compact()
- .join('\n\n')
- .value()
- }
+ const appendMsg = _.chain([
+ 'Cypress could not associate this error to any specific test.',
+ 'We dynamically generated a new test to display this failure.',
+ todoMsg(),
+ ])
+ .compact()
+ .join('\n\n')
+ .value()
- // else do the same thing as mocha here
- err = $errUtils.appendErrMsg(err, append())
+ err = $errUtils.appendErrMsg(err, appendMsg)
const throwErr = function () {
throw err
@@ -828,6 +824,12 @@ const create = function (specWindow, mocha, Cypress, cy) {
return undefined
}
+ specWindow.onerror = function () {
+ const err = cy.onSpecWindowUncaughtException.apply(cy, arguments)
+
+ return onScriptError(err)
+ }
+
// hold onto the _runnables for faster lookup later
let _stopped = false
let _test = null
@@ -892,6 +894,8 @@ const create = function (specWindow, mocha, Cypress, cy) {
overrideRunnerHook(Cypress, _runner, getTestById, getTest, setTest, getTests)
return {
+ onScriptError,
+
normalizeAll (tests) {
// if we have an uncaught error then slice out
// all of the tests and suites and just generate
diff --git a/packages/driver/src/cypress/script_utils.js b/packages/driver/src/cypress/script_utils.js
new file mode 100644
index 000000000000..c29fb36591f2
--- /dev/null
+++ b/packages/driver/src/cypress/script_utils.js
@@ -0,0 +1,38 @@
+const _ = require('lodash')
+const Promise = require('bluebird')
+
+const $networkUtils = require('./network_utils')
+const $sourceMapUtils = require('./source_map_utils')
+
+const fetchScript = (scriptWindow, script) => {
+ return $networkUtils.fetch(script.relativeUrl, scriptWindow)
+ .then((contents) => {
+ return [script, contents]
+ })
+}
+
+const extractSourceMap = ([script, contents]) => {
+ script.fullyQualifiedUrl = `${window.top.location.origin}${script.relativeUrl}`
+
+ return $sourceMapUtils.extractSourceMap(script, contents)
+ .return([script, contents])
+}
+
+const evalScripts = (specWindow, scripts = []) => {
+ _.each(scripts, ([script, contents]) => {
+ specWindow.eval(`${contents}\n//# sourceURL=${script.fullyQualifiedUrl}`)
+ })
+
+ return null
+}
+
+const runScripts = (specWindow, scripts) => {
+ return Promise
+ .map(scripts, (script) => fetchScript(specWindow, script))
+ .map(extractSourceMap)
+ .then((scripts) => evalScripts(specWindow, scripts))
+}
+
+module.exports = {
+ runScripts,
+}
diff --git a/packages/driver/src/cypress/source_map_utils.js b/packages/driver/src/cypress/source_map_utils.js
new file mode 100644
index 000000000000..ab36fa1f5571
--- /dev/null
+++ b/packages/driver/src/cypress/source_map_utils.js
@@ -0,0 +1,83 @@
+const { SourceMapConsumer } = require('source-map')
+const Promise = require('bluebird')
+
+const baseSourceMapRegex = '\\s*[@#]\\s*sourceMappingURL\\s*=\\s*([^\\s]*)(?![\\S\\s]*sourceMappingURL)'
+const regexCommentStyle1 = new RegExp(`/\\*${baseSourceMapRegex}\\s*\\*/`) // matches /* ... */ comments
+const regexCommentStyle2 = new RegExp(`//${baseSourceMapRegex}($|\n|\r\n?)`) // matches // .... comments
+const regexDataUrl = /data:[^;\n]+(?:;charset=[^;\n]+)?;base64,([a-zA-Z0-9+/]+={0,2})/ // matches data urls
+
+let sourceMapConsumers = {}
+
+const initialize = (file, sourceMapBase64) => {
+ SourceMapConsumer.initialize({
+ 'lib/mappings.wasm': require('source-map/lib/mappings.wasm'),
+ })
+
+ const sourceMap = base64toJs(sourceMapBase64)
+
+ return Promise.resolve(new SourceMapConsumer(sourceMap)).then((consumer) => {
+ sourceMapConsumers[file.fullyQualifiedUrl] = consumer
+
+ return consumer
+ })
+}
+
+const extractSourceMap = (file, fileContents) => {
+ const sourceMapMatch = fileContents.match(regexCommentStyle1) || fileContents.match(regexCommentStyle2)
+
+ if (!sourceMapMatch) return Promise.resolve(null)
+
+ const url = sourceMapMatch[1]
+ const dataUrlMatch = url.match(regexDataUrl)
+
+ if (!dataUrlMatch) return Promise.resolve(null)
+
+ const sourceMapBase64 = dataUrlMatch[1]
+
+ return initialize(file, sourceMapBase64)
+}
+
+const getSourceContents = (filePath, sourceFile) => {
+ if (!sourceMapConsumers[filePath]) return null
+
+ try {
+ return sourceMapConsumers[filePath].sourceContentFor(sourceFile)
+ } catch (err) {
+ // ignore the sourceFile not being in the source map. there's nothing we
+ // can do about it and we don't want to thrown an exception
+ if (err && err.message.indexOf('not in the SourceMap') > -1) return
+
+ throw err
+ }
+}
+
+const getSourcePosition = (filePath, position) => {
+ if (!sourceMapConsumers[filePath]) return null
+
+ const sourcePosition = sourceMapConsumers[filePath].originalPositionFor(position)
+ const { source: file, line, column } = sourcePosition
+
+ if (!file || line == null || column == null) return
+
+ return {
+ file,
+ line,
+ column,
+ }
+}
+
+const base64toJs = (base64) => {
+ const mapString = atob(base64)
+
+ try {
+ return JSON.parse(mapString)
+ } catch (err) {
+ return null
+ }
+}
+
+module.exports = {
+ extractSourceMap,
+ getSourceContents,
+ getSourcePosition,
+}
diff --git a/packages/driver/src/cypress/stack_utils.js b/packages/driver/src/cypress/stack_utils.js
new file mode 100644
index 000000000000..1d8d7f3b0293
--- /dev/null
+++ b/packages/driver/src/cypress/stack_utils.js
@@ -0,0 +1,333 @@
+const _ = require('lodash')
+const { codeFrameColumns } = require('@babel/code-frame')
+const errorStackParser = require('error-stack-parser')
+const path = require('path')
+
+const $sourceMapUtils = require('./source_map_utils')
+const $utils = require('./utils')
+
+const whitespaceRegex = /^(\s*)*/
+const stackLineRegex = /^\s*(at )?.*@?\(?.*\:\d+\:\d+\)?$/
+const STACK_REPLACEMENT_MARKER = '__stackReplacementMarker'
+
+// returns tuple of [message, stack]
+const splitStack = (stack) => {
+ const lines = stack.split('\n')
+
+ return _.reduce(lines, (memo, line) => {
+ if (memo.messageEnded || stackLineRegex.test(line)) {
+ memo.messageEnded = true
+ memo[1].push(line)
+ } else {
+ memo[0].push(line)
+ }
+
+ return memo
+ }, [[], []])
+}
+
+const unsplitStack = (messageLines, stackLines) => {
+ return _.castArray(messageLines).concat(stackLines).join('\n')
+}
+
+const getStackLines = (stack) => {
+ const [, stackLines] = splitStack(stack)
+
+ return stackLines
+}
+
+const stackWithoutMessage = (stack) => {
+ return getStackLines(stack).join('\n')
+}
+
+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) => {
+ return stackWithLinesRemoved(stack, (lines) => {
+ // drop lines above the marker
+ const withAboveMarkerRemoved = _.dropWhile(lines, (line) => {
+ return !_.includes(line, marker)
+ })
+
+ // remove the first line because it includes the marker
+ return 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,
+ relativeFile,
+ absoluteFile,
+ frame,
+ language: getLanguageFromExtension(relativeFile),
+ }
+}
+
+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, relativeFile } = stackLine
+
+ return getCodeFrameFromSource($sourceMapUtils.getSourceContents(fileUrl, relativeFile), stackLine)
+}
+
+const getWhitespace = (line) => {
+ if (!line) return ''
+
+ const [, whitespace] = line.match(whitespaceRegex) || []
+
+ return whitespace || ''
+}
+
+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,
+ function: fn,
+ }
+}
+
+const functionExtrasRegex = /(\/<|<\/<)$/
+
+const cleanFunctionName = (functionName) => {
+ if (!_.isString(functionName)) return ''
+
+ 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 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)
+
+ return {
+ function: sourceDetails.function,
+ fileUrl: generatedDetails.file,
+ relativeFile: sourceDetails.file,
+ absoluteFile: path.join(projectRoot, sourceDetails.file),
+ line: sourceDetails.line,
+ // adding 1 to column makes more sense for code frame and opening in editor
+ column: sourceDetails.column + 1,
+ whitespace,
+ }
+}
+
+const reconstructStack = (parsedStack) => {
+ return _.map(parsedStack, (parsedLine) => {
+ if (parsedLine.message != null) {
+ return `${parsedLine.whitespace}${parsedLine.message}`
+ }
+
+ const { whitespace, relativeFile, function: fn, line, column } = parsedLine
+
+ return `${whitespace}at ${fn} (${relativeFile || ''}:${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) => {
+ return line.includes('cy[name]') || line.includes('Chainer.prototype[key]')
+ }).join('\n')
+
+ return normalizeStackIndentation(winnowedStackLines)
+}
+
+const replacedStack = (err, newStack) => {
+ // if err already lacks a stack or we've removed the stack
+ // for some reason, keep it stackless
+ if (!err.stack) return err.stack
+
+ const errString = err.toString()
+ const stackLines = getStackLines(newStack)
+
+ return unsplitStack(errString, stackLines)
+}
+
+module.exports = {
+ getCodeFrame,
+ getSourceStack,
+ getStackLines,
+ hasCrossFrameStacks,
+ normalizedStack,
+ normalizedUserInvocationStack,
+ replacedStack,
+ stackWithContentAppended,
+ stackWithLinesDroppedFromMarker,
+ stackWithoutMessage,
+ stackWithReplacementMarkerLineRemoved,
+ stackWithUserInvocationStackSpliced,
+}
diff --git a/packages/driver/src/cypress/utils.coffee b/packages/driver/src/cypress/utils.coffee
index 8c295b631683..68f2c9f2b5a6 100644
--- a/packages/driver/src/cypress/utils.coffee
+++ b/packages/driver/src/cypress/utils.coffee
@@ -252,4 +252,24 @@ module.exports = {
memoized.cache = cacheInstance
return memoized
+
+ indent: (str, indentAmount) ->
+ indentStr = _.repeat(" ", indentAmount)
+
+ str = str.replace(/\n/g, "\n#{indentStr}")
+
+ "#{indentStr}#{str}"
+
+ ## normalize more than {maxNewLines} new lines into
+ ## exactly {replacementNumLines} new lines
+ normalizeNewLines: (str, maxNewLines, replacementNumLines) ->
+ moreThanMaxNewLinesRe = new RegExp("\\n{#{maxNewLines},}")
+ replacementWithNumLines = replacementNumLines ? maxNewLines
+
+ _
+ .chain(str)
+ .split(moreThanMaxNewLinesRe)
+ .compact()
+ .join(_.repeat("\n", replacementWithNumLines))
+ .value()
}
diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.js b/packages/driver/test/cypress/integration/commands/assertions_spec.js
index fda6ecf69697..7e4c1b1dd9b0 100644
--- a/packages/driver/test/cypress/integration/commands/assertions_spec.js
+++ b/packages/driver/test/cypress/integration/commands/assertions_spec.js
@@ -838,7 +838,7 @@ describe('src/cy/commands/assertions', () => {
expected: false,
actual: true,
Message: 'expected true to be false',
- Error: log.get('error').toString(),
+ Error: log.get('error').stack,
})
done()
diff --git a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee
index 58119cba44d0..8f35bf8abe09 100644
--- a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee
+++ b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee
@@ -1344,15 +1344,7 @@ describe "src/cy/commands/connectors", ->
expect(@lastLog.invoke("consoleProps")).to.deep.eq {
Command: "its"
Property: ".fizz.buzz"
- Error: """
- CypressError: Timed out retrying: `cy.its()` errored because the property: `fizz` does not exist on your subject.
-
- `cy.its()` waited for the specified property `fizz` to exist, but it never did.
-
- If you do not expect the property `fizz` to exist, then add an assertion such as:
-
- `cy.wrap({ foo: 'bar' }).its('quux').should('not.exist')`
- """
+ Error: @lastLog.get("error").stack
Subject: {foo: "bar"}
Yielded: undefined
}
diff --git a/packages/driver/test/cypress/integration/commands/cookies_spec.coffee b/packages/driver/test/cypress/integration/commands/cookies_spec.coffee
index 095687907060..6c49333346b3 100644
--- a/packages/driver/test/cypress/integration/commands/cookies_spec.coffee
+++ b/packages/driver/test/cypress/integration/commands/cookies_spec.coffee
@@ -123,7 +123,6 @@ describe "src/cy/commands/cookies", ->
expect(lastLog.get("error").message).to.contain "`cy.getCookies()` had an unexpected error reading cookies from #{Cypress.browser.displayName}."
expect(lastLog.get("error").message).to.contain "some err message"
- expect(lastLog.get("error").message).to.contain error.stack
done()
cy.getCookies()
@@ -260,7 +259,6 @@ describe "src/cy/commands/cookies", ->
expect(lastLog.get("error").message).to.contain "`cy.getCookie()` had an unexpected error reading the requested cookie from #{Cypress.browser.displayName}."
expect(lastLog.get("error").message).to.contain "some err message"
- expect(lastLog.get("error").message).to.contain error.stack
done()
cy.getCookie("foo")
@@ -466,7 +464,6 @@ describe "src/cy/commands/cookies", ->
expect(@logs.length).to.eq(1)
expect(lastLog.get("error").message).to.include "some err message"
expect(lastLog.get("error").name).to.eq "CypressError"
- expect(lastLog.get("error").stack).to.include error.stack
done()
cy.setCookie("foo", "bar")
@@ -666,7 +663,6 @@ describe "src/cy/commands/cookies", ->
expect(@logs.length).to.eq(1)
expect(lastLog.get("error").message).to.contain "`cy.clearCookie()` had an unexpected error clearing the requested cookie in #{Cypress.browser.displayName}."
expect(lastLog.get("error").message).to.contain "some err message"
- expect(lastLog.get("error").message).to.contain error.stack
done()
cy.clearCookie("foo")
@@ -878,7 +874,7 @@ describe "src/cy/commands/cookies", ->
it "logs once on 'get:cookies' error", (done) ->
error = new Error("some err message")
error.name = "foo"
- error.stack = "stack"
+ error.stack = "some err message\n at fn (foo.js:1:1)"
Cypress.automation.rejects(error)
@@ -888,7 +884,6 @@ describe "src/cy/commands/cookies", ->
expect(@logs.length).to.eq(1)
expect(lastLog.get("error").message).to.contain "`cy.clearCookies()` had an unexpected error clearing cookies in #{Cypress.browser.displayName}."
expect(lastLog.get("error").message).to.contain "some err message"
- expect(lastLog.get("error").message).to.contain error.stack
expect(lastLog.get("error")).to.eq(err)
done()
@@ -929,7 +924,6 @@ describe "src/cy/commands/cookies", ->
expect(@logs.length).to.eq(1)
expect(lastLog.get("error").message).to.contain "`cy.clearCookies()` had an unexpected error clearing cookies in #{Cypress.browser.displayName}."
expect(lastLog.get("error").message).to.contain "some err message"
- expect(lastLog.get("error").message).to.contain error.stack
expect(lastLog.get("error")).to.eq(err)
done()
diff --git a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee
index 6e52c4266542..5364db693292 100644
--- a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee
+++ b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee
@@ -1229,8 +1229,6 @@ describe "src/cy/commands/navigation", ->
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
- The stack trace for this error is:
""")
expect(err1.url).to.include("/foo.html")
expect(emit).to.be.calledWith("visit:failed", err1)
diff --git a/packages/driver/test/cypress/integration/commands/request_spec.coffee b/packages/driver/test/cypress/integration/commands/request_spec.coffee
index b828270f711a..2d2b83442cb0 100644
--- a/packages/driver/test/cypress/integration/commands/request_spec.coffee
+++ b/packages/driver/test/cypress/integration/commands/request_spec.coffee
@@ -1042,8 +1042,6 @@ describe "src/cy/commands/request", ->
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
- The stack trace for this error is:
""")
expect(err.docsUrl).to.eq("https://on.cypress.io/request")
done()
diff --git a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee
index 7a9760b0d4aa..cdaacded7405 100644
--- a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee
+++ b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee
@@ -832,7 +832,7 @@ describe "src/cy/commands/xhr", ->
expect(@logs.length).to.eq(1)
expect(lastLog.get("name")).to.eq("xhr")
- expect(lastLog.get("error")).to.eq err
+ expect(err).to.eq(lastLog.get("error"))
expect(err).to.eq(e)
done()
@@ -950,7 +950,7 @@ describe "src/cy/commands/xhr", ->
lastLog = @lastLog
_.each obj, (value, key) =>
- expect(lastLog.get(key)).deep.eq(value, "expected key: #{key} to eq value: #{value}")
+ expect(value).deep.eq(lastLog.get(key), "expected key: #{key} to eq value: #{value}")
done()
@@ -960,6 +960,7 @@ describe "src/cy/commands/xhr", ->
.window().then (win) ->
win.$.get("/foo").done ->
throw new Error("specific ajax error")
+
## FIXME: I have no idea why this is skipped, this test is rly old
context.skip "#server", ->
beforeEach ->
@@ -1591,7 +1592,7 @@ describe "src/cy/commands/xhr", ->
## route + window + xhr log === 3
expect(@logs.length).to.eq(3)
expect(lastLog.get("name")).to.eq("xhr")
- expect(lastLog.get("error")).to.eq err
+ expect(err).to.eq(lastLog.get("error"))
done()
cy
@@ -1599,6 +1600,7 @@ describe "src/cy/commands/xhr", ->
.window().then (win) ->
win.$.get("foo_bar").done ->
foo.bar()
+
## FIXME: I have no idea why this is skipped, this test is rly old
it.skip "explodes if response fixture signature errors", (done) ->
@trigger = cy.stub(@Cypress, "trigger").withArgs("fixture").callsArgWithAsync(2, {__error: "some error"})
@@ -2175,12 +2177,12 @@ describe "src/cy/commands/xhr", ->
context "options immutability", ->
it "does not mutate options for cy.server()", ->
options = { enable: false }
-
+
cy
.server(options)
.window().then ->
expect(options).to.deep.eq({ enable: false })
-
+
it "does not mutate options for cy.route()", ->
options = {
url: /foo/
diff --git a/packages/driver/test/cypress/integration/cypress/cy_spec.js b/packages/driver/test/cypress/integration/cypress/cy_spec.js
index 7de4d687a584..bec34be0027f 100644
--- a/packages/driver/test/cypress/integration/cypress/cy_spec.js
+++ b/packages/driver/test/cypress/integration/cypress/cy_spec.js
@@ -123,6 +123,27 @@ describe('driver/src/cypress/cy', () => {
)
})
})
+
+ it('stores invocation stack for first command', () => {
+ cy
+ .get('input:first')
+ .then(() => {
+ const userInvocationStack = cy.queue.find({ name: 'get' }).get('userInvocationStack')
+
+ expect(userInvocationStack).to.include('cy_spec.js')
+ })
+ })
+
+ it('stores invocation stack for chained command', () => {
+ cy
+ .get('div')
+ .find('input')
+ .then(() => {
+ const userInvocationStack = cy.queue.find({ name: 'find' }).get('userInvocationStack')
+
+ expect(userInvocationStack).to.include('cy_spec.js')
+ })
+ })
})
context('custom commands', () => {
@@ -153,6 +174,36 @@ describe('driver/src/cypress/cy', () => {
})
})
+ describe('invocation stack', () => {
+ beforeEach(() => {
+ Cypress.Commands.add('getInput', () => cy.get('input'))
+ Cypress.Commands.add('findInput', { prevSubject: 'element' }, (subject) => {
+ subject.find('input')
+ })
+ })
+
+ it('stores invocation stack for first command', () => {
+ cy
+ .getInput()
+ .then(() => {
+ const userInvocationStack = cy.queue.find({ name: 'getInput' }).get('userInvocationStack')
+
+ expect(userInvocationStack).to.include('cy_spec.js')
+ })
+ })
+
+ it('stores invocation stack for chained command', () => {
+ cy
+ .get('div')
+ .findInput()
+ .then(() => {
+ const userInvocationStack = cy.queue.find({ name: 'findInput' }).get('userInvocationStack')
+
+ expect(userInvocationStack).to.include('cy_spec.js')
+ })
+ })
+ })
+
describe('parent commands', () => {
it('ignores existing subject', () => {
Cypress.Commands.add('bar', (arg1, arg2) => {
diff --git a/packages/driver/test/cypress/integration/cypress/cypress_spec.js b/packages/driver/test/cypress/integration/cypress/cypress_spec.js
index 33b30f6babeb..e3115b6ed259 100644
--- a/packages/driver/test/cypress/integration/cypress/cypress_spec.js
+++ b/packages/driver/test/cypress/integration/cypress/cypress_spec.js
@@ -121,4 +121,61 @@ describe('driver/src/cypress/index', () => {
expect(fn).to.not.throw()
})
})
+
+ context('deprecated custom command methods', () => {
+ it('throws when using Cypress.addParentCommand', () => {
+ const addParentCommand = () => Cypress.addParentCommand()
+
+ expect(addParentCommand).to.throw().and.satisfy((err) => {
+ expect(err.message).to.include('Cypress.addParentCommand(...) has been removed and replaced')
+ expect(err.docsUrl).to.equal('https://on.cypress.io/custom-command-interface-changed')
+
+ return true
+ })
+ })
+
+ it('throws when using Cypress.addChildCommand', () => {
+ const addChildCommand = () => Cypress.addChildCommand()
+
+ expect(addChildCommand).to.throw().and.satisfy((err) => {
+ expect(err.message).to.include('Cypress.addChildCommand(...) has been removed and replaced')
+ expect(err.docsUrl).to.equal('https://on.cypress.io/custom-command-interface-changed')
+
+ return true
+ })
+ })
+
+ it('throws when using Cypress.addDualCommand', () => {
+ const addDualCommand = () => Cypress.addDualCommand()
+
+ expect(addDualCommand).to.throw().and.satisfy((err) => {
+ expect(err.message).to.include('Cypress.addDualCommand(...) has been removed and replaced')
+ expect(err.docsUrl).to.equal('https://on.cypress.io/custom-command-interface-changed')
+
+ return true
+ })
+ })
+ })
+
+ context('private command methods', () => {
+ it('throws when using Cypress.addAssertionCommand', () => {
+ const addAssertionCommand = () => Cypress.addAssertionCommand()
+
+ expect(addAssertionCommand).to.throw().and.satisfy((err) => {
+ expect(err.message).to.include('You cannot use the undocumented private command interface: `addAssertionCommand`')
+
+ return true
+ })
+ })
+
+ it('throws when using Cypress.addUtilityCommand', () => {
+ const addUtilityCommand = () => Cypress.addUtilityCommand()
+
+ expect(addUtilityCommand).to.throw().and.satisfy((err) => {
+ expect(err.message).to.include('You cannot use the undocumented private command interface: `addUtilityCommand`')
+
+ return true
+ })
+ })
+ })
})
diff --git a/packages/driver/test/cypress/integration/cypress/error_utils_spec.js b/packages/driver/test/cypress/integration/cypress/error_utils_spec.js
index 24ce74cf4d90..66f5d9c4951f 100644
--- a/packages/driver/test/cypress/integration/cypress/error_utils_spec.js
+++ b/packages/driver/test/cypress/integration/cypress/error_utils_spec.js
@@ -1,7 +1,44 @@
const $errUtils = require('../../../../src/cypress/error_utils')
+const $stackUtils = require('../../../../src/cypress/stack_utils')
const $errorMessages = require('../../../../src/cypress/error_messages')
describe('driver/src/cypress/error_utils', () => {
+ context('.modifyErrMsg', () => {
+ let originalErr
+ let newErrMsg
+ let modifier
+
+ beforeEach(() => {
+ originalErr = new Error('simple foo message')
+ originalErr.name = 'FooError'
+ newErrMsg = 'new message'
+ modifier = (msg1, msg2) => `${msg2} ${msg1}`
+ })
+
+ it('returns new error object with message modified by callback', () => {
+ const err = $errUtils.modifyErrMsg(originalErr, newErrMsg, modifier)
+
+ expect(err.name).to.eq('FooError')
+ expect(err.message).to.eq('new message simple foo message')
+ })
+
+ it('replaces stack error message', () => {
+ originalErr.stack = `${originalErr.name}: ${originalErr.message}\nline 2\nline 3`
+ const err = $errUtils.modifyErrMsg(originalErr, newErrMsg, modifier)
+
+ expect(err.stack).to.equal('FooError: new message simple foo message\nline 2\nline 3')
+ })
+
+ it('keeps other properties in place from original error', () => {
+ originalErr.actual = 'foo'
+ originalErr.expected = 'bar'
+ const err = $errUtils.modifyErrMsg(originalErr, newErrMsg, modifier)
+
+ expect(err.actual).to.equal('foo')
+ expect(err.expected).to.equal('bar')
+ })
+ })
+
context('.throwErr', () => {
it('throws error as a cypress error when it is a message string', () => {
const fn = () => {
@@ -47,20 +84,32 @@ describe('driver/src/cypress/error_utils', () => {
})
})
- it('removes stack if noStackTrace: true', () => {
- const fn = () => {
- $errUtils.throwErr('Something unexpected', { noStackTrace: true })
- }
+ it('attaches onFail to the error when it is a function', () => {
+ const onFail = function () {}
+ const fn = () => $errUtils.throwErr(new Error('foo'), { onFail })
- expect(fn).to.throw().and.satisfy((err) => {
- expect(err.stack).to.equal('')
+ expect(fn).throw().and.satisfy((err) => {
+ expect(err.onFail).to.equal(onFail)
+
+ return true
+ })
+ })
+
+ it('attaches onFail to the error when it is a command', () => {
+ const command = { error: cy.spy() }
+ const fn = () => $errUtils.throwErr(new Error('foo'), { onFail: command })
+
+ expect(fn).throw().and.satisfy((err) => {
+ err.onFail('the error')
+
+ expect(command.error).to.be.calledWith('the error')
return true
})
})
})
- context('.throwErrByPath', () => {
+ context('.errByPath', () => {
beforeEach(() => {
$errorMessages.__test_errors = {
obj: {
@@ -122,229 +171,269 @@ describe('driver/src/cypress/error_utils', () => {
}
})
- describe('when error message path does not exist', () => {
- it('has an err.name of InternalError', () => {
- try {
- $errUtils.throwErrByPath('not.there')
- } catch (e) {
- expect(e.name).to.eq('InternalError')
- }
+ it('returns internal error when message path does not exist', () => {
+ const err = $errUtils.errByPath('not.there')
+
+ expect(err.name).to.eq('InternalCypressError')
+ expect(err.message).to.include(`Error message path 'not.there' does not exist`)
+ })
+
+ describe('when message value is an object', () => {
+ it('has correct name, message, and docs url when path exists', () => {
+ const err = $errUtils.errByPath('__test_errors.obj')
+
+ expect(err.name).to.eq('CypressError')
+ expect(err.message).to.include('This is a simple error message')
+ expect(err.docsUrl).to.include('https://on.link.io')
+ })
+
+ it('uses args provided for the error', () => {
+ const err = $errUtils.errByPath('__test_errors.obj_with_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
+ })
+
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux')
+ expect(err.docsUrl).to.include('https://on.link.io')
+ })
+
+ it('handles args being used multiple times in message', () => {
+ const err = $errUtils.errByPath('__test_errors.obj_with_multi_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
+ })
+
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux, and \'foo\' is used twice')
+ expect(err.docsUrl).to.include('https://on.link.io')
})
- it('has the right message', () => {
- try {
- $errUtils.throwErrByPath('not.there')
- } catch (e) {
- expect(e.message).to.include('Error message path \'not.there\' does not exist')
- }
+ it('formats markdown in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.obj_with_markdown', {
+ foo: 'foo', bar: ['bar', 'qux'],
+ })
+
+ expect(err.message).to.include('This has markdown like `foo`, *bar,qux*, **foo**, and _bar,qux_')
+ expect(err.docsUrl).to.include('https://on.link.io')
})
})
- describe('when error message path exists', () => {
- context('error is string', () => {
- describe('when no args are provided for the error', () => {
- it('has an err.name of CypressError', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.str')
- } catch (e) {
- expect(e.name).to.eq('CypressError')
- }
- })
+ describe('when message value is a string', () => {
+ it('has correct name, message, and docs url', () => {
+ const err = $errUtils.errByPath('__test_errors.str')
- it('has the right message and docs url', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.str')
- } catch (e) {
- expect(e.message).to.include('This is a simple error message')
- }
- })
+ expect(err.name).to.eq('CypressError')
+ expect(err.message).to.include('This is a simple error message')
+ expect(err.docsUrl).to.be.undefined
+ })
+
+ it('uses args provided for the error', () => {
+ const err = $errUtils.errByPath('__test_errors.str_with_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
- describe('when args are provided for the error', () => {
- it('uses them in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.str_with_args', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has args like \'foo\' and bar,qux')
- }
- })
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux')
+ })
+
+ it('handles args being used multiple times in message', () => {
+ const err = $errUtils.errByPath('__test_errors.str_with_multi_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
- describe('when args are provided for the error and some are used multiple times in message', () => {
- it('uses them in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.str_with_multi_args', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has args like \'foo\' and bar,qux, and \'foo\' is used twice')
- }
- })
+ expect(err.message).to.include(`This has args like 'foo' and bar,qux, and 'foo' is used twice`)
+ })
+
+ it('formats markdown in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.str_with_markdown', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
- describe('when markdown and args', () => {
- it('formats markdown in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.str_with_markdown', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has markdown like `foo`, *bar,qux*, **foo**, and _bar,qux_')
- }
- })
+ expect(err.message).to.include('This has markdown like `foo`, *bar,qux*, **foo**, and _bar,qux_')
+ })
+ })
+
+ describe('when message value is a function that returns a string', () => {
+ it('has correct name and message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn')
+
+ expect(err.name).to.eq('CypressError')
+ expect(err.message).to.include('This is a simple error message')
+
+ return true
+ })
+
+ it('uses args in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_with_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
+
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux')
})
- context('error is function that returns a string', () => {
- describe('when no args are provided for the error', () => {
- it('has an err.name of CypressError', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.fn')
- } catch (e) {
- expect(e.name).to.eq('CypressError')
- }
- })
+ it('handles args being used multiple times in message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_with_multi_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
+ })
- it('has the right message and docs url', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.fn')
- } catch (e) {
- expect(e.message).to.include('This is a simple error message')
- }
- })
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux, and \'foo\' is used twice')
+ })
+
+ it('formats markdown in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_with_markdown', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
- describe('when args are provided for the error', () => {
- it('uses them in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.fn_with_args', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has args like \'foo\' and bar,qux')
- }
- })
+ expect(err.message).to.include('This has markdown like `foo`, *bar,qux*, **foo**, and _bar,qux_')
+ })
+ })
+
+ describe('when message value is a function that returns an object', () => {
+ describe('when no args are provided for the error', () => {
+ it('has an err.name of CypressError', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_returns_obj')
+
+ expect(err.name).to.eq('CypressError')
})
- describe('when args are provided for the error and some are used multiple times in message', () => {
- it('uses them in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.fn_with_multi_args', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has args like \'foo\' and bar,qux, and \'foo\' is used twice')
- }
+ it('has the right message and docs url', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_returns_obj')
+
+ expect(err.message).to.include('This is a simple error message')
+ expect(err.docsUrl).to.include('https://on.link.io')
+ })
+ })
+
+ describe('when args are provided for the error', () => {
+ it('uses them in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_returns_obj_with_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
+
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux')
+ expect(err.docsUrl).to.include('https://on.link.io')
})
+ })
- describe('when markdown and args', () => {
- it('formats markdown in the error message', () => {
- try {
- $errUtils.throwErrByPath('__test_errors.fn_with_markdown', {
- args: {
- foo: 'foo', bar: ['bar', 'qux'],
- },
- })
- } catch (e) {
- expect(e.message).to.include('This has markdown like `foo`, *bar,qux*, **foo**, and _bar,qux_')
- }
+ describe('when args are provided for the error and some are used multiple times in message', () => {
+ it('uses them in the error message', () => {
+ const err = $errUtils.errByPath('__test_errors.fn_returns_obj_with_multi_args', {
+ foo: 'foo', bar: ['bar', 'qux'],
})
+
+ expect(err.message).to.include('This has args like \'foo\' and bar,qux, and \'foo\' is used twice')
+ expect(err.docsUrl).to.include('https://on.link.io')
})
})
})
+ })
- describe('when onFail is provided as a function', () => {
- it('attaches the function to the error', () => {
- const onFail = function () {}
+ context('.throwErrByPath', () => {
+ let fn
- try {
- $errUtils.throwErrByPath('__test_errors.obj', { onFail })
- } catch (e) {
- expect(e.onFail).to.equal(onFail)
- }
- })
+ beforeEach(() => {
+ $errorMessages.__test_errors = {
+ test: 'Simple error {{message}}',
+ }
+
+ // build up a little stack
+ const throwingFn = () => {
+ $errUtils.throwErrByPath('__test_errors.test', {
+ args: { message: 'with a message' },
+ })
+ }
+
+ fn = () => throwingFn()
+ })
+
+ it('looks up error and throws it', () => {
+ expect(fn).to.throw('Simple error with a message')
})
- describe('when onFail is provided as a command', () => {
- it('attaches the handler to the error', () => {
- const command = { error: cy.spy() }
+ it('removes internal stack lines from stack', () => {
+ // this features relies on Error.captureStackTrace, which some
+ // browsers don't have (e.g. Firefox)
+ if (!Error.captureStackTrace) return
- try {
- $errUtils.throwErrByPath('__test_errors.obj', { onFail: command })
- } catch (e) {
- e.onFail('the error')
+ expect(fn).to.throw().and.satisfies((err) => {
+ expect(err.stack).to.include('throwingFn')
+ expect(err.stack).not.to.include('throwErrByPath')
+ expect(err.stack).not.to.include('errByPath')
+ expect(err.stack).not.to.include('cypressErr')
- expect(command.error).to.be.calledWith('the error')
- }
+ return true
})
})
})
- context('.getObjValueByPath', () => {
- let obj
+ context('.throwErrByPath', () => {
+ it('looks up error and throws it', () => {
+ $errorMessages.__test_error = 'simple error message'
+
+ const fn = () => $errUtils.throwErrByPath('__test_error')
+
+ expect(fn).to.throw('simple error message')
+ })
+ })
+
+ context('.enhanceStack', () => {
+ const userInvocationStack = ' at userInvoked (app.js:1:1)'
+ const sourceStack = {
+ sourceMapped: 'source mapped stack',
+ parsed: [],
+ }
+ const codeFrame = {}
+ let err
beforeEach(() => {
- obj = {
- foo: 'foo',
- bar: {
- baz: {
- qux: 'qux',
- },
- },
- }
+ cy.stub($stackUtils, 'replacedStack').returns('replaced stack')
+ cy.stub($stackUtils, 'stackWithUserInvocationStackSpliced').returns({ stack: 'spliced stack' })
+ cy.stub($stackUtils, 'getSourceStack').returns(sourceStack)
+ cy.stub($stackUtils, 'getCodeFrame').returns(codeFrame)
+
+ err = { stack: 'Error: original stack message\n at originalStack (foo.js:1:1)' }
})
- it('throws if object not provided as first argument', () => {
- const fn = () => {
- return $errUtils.getObjValueByPath('foo')
- }
+ it('replaces stack with user invocation stack', () => {
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
- expect(fn).to.throw('The first parameter to utils.getObjValueByPath() must be an object')
+ expect(result.stack).to.equal('replaced stack')
})
- it('throws if path not provided as second argument', () => {
- const fn = () => {
- return $errUtils.getObjValueByPath(obj)
- }
+ it('attaches source mapped stack', () => {
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
+
+ expect(result.sourceMappedStack).to.equal(sourceStack.sourceMapped)
+ })
+
+ it('attaches parsed stack', () => {
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
- expect(fn).to.throw('The second parameter to utils.getObjValueByPath() must be a string')
+ expect(result.parsedStack).to.equal(sourceStack.parsed)
})
- it('returns value for shallow path', () => {
- const objVal = $errUtils.getObjValueByPath(obj, 'foo')
+ it('attaches code frame', () => {
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
- expect(objVal).to.equal('foo')
+ expect(result.codeFrame).to.equal(codeFrame)
})
- it('returns value for deeper path', () => {
- const objVal = $errUtils.getObjValueByPath(obj, 'bar.baz.qux')
+ it('appends user invocation stack when it is a cypress error', () => {
+ err.name = 'CypressError'
- expect(objVal).to.equal('qux')
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
+
+ expect(result.stack).to.equal('spliced stack')
})
- it('returns undefined for non-existent shallow path', () => {
- const objVal = $errUtils.getObjValueByPath(obj, 'nope')
+ it('appends user invocation stack when it is a chai validation error', () => {
+ err.message = 'Invalid Chai property'
+
+ const result = $errUtils.enhanceStack({ err, userInvocationStack })
- expect(objVal).to.be.undefined
+ expect(result.stack).to.equal('spliced stack')
})
- it('returns undefined for non-existent deeper path', () => {
- const objVal = $errUtils.getObjValueByPath(obj, 'bar.baz.nope')
+ it('does not replaced or append stack when there is no invocation stack', () => {
+ const result = $errUtils.enhanceStack({ err })
- expect(objVal).to.be.undefined
+ expect(result.stack).to.equal(err.stack)
})
})
@@ -367,49 +456,83 @@ describe('driver/src/cypress/error_utils', () => {
})
})
- context('.modifyErrName', () => {
- it('returns same error', () => {
- const err = new Error('message')
- const result = $errUtils.modifyErrName(err, 'New Name')
+ context('.createUncaughtException', () => {
+ let err
+
+ beforeEach(() => {
+ err = new Error('original message')
+ err.stack = 'Error: original message\n\nat foo (path/to/file:1:1)'
+ })
+
+ it('mutates the error passed in and returns it', () => {
+ const result = $errUtils.createUncaughtException('spec', err)
expect(result).to.equal(err)
})
- it('replaces name in err', () => {
- const err = new Error('message')
- const result = $errUtils.modifyErrName(err, 'New Name')
+ it('replaces message with wrapper message for spec error', () => {
+ const result = $errUtils.createUncaughtException('spec', err)
+
+ expect(result.message).to.include('The following error originated from your test code, not from Cypress')
+ expect(result.message).to.include('> original message')
+ })
+
+ it('replaces message with wrapper message for app error', () => {
+ const result = $errUtils.createUncaughtException('app', err)
- expect(result.name).to.equal('New Name')
+ expect(result.message).to.include('The following error originated from your application code, not from Cypress')
+ expect(result.message).to.include('> original message')
})
- it('replaces stack to include new name', () => {
- const err = new Error('message')
+ it('replaces original name and message in stack', () => {
+ const result = $errUtils.createUncaughtException('spec', err)
+
+ expect(result.stack).not.to.include('Error: original message')
+ })
- $errUtils.normalizeErrorStack(err)
- const result = $errUtils.modifyErrName(err, 'New Name')
+ it('retains the stack of the original error', () => {
+ const result = $errUtils.createUncaughtException('spec', err)
- expect(result.stack).to.include('New Name: message')
+ expect(result.stack).to.include('at foo (path/to/file:1:1)')
})
- })
- context('.replacedStack', () => {
- it('returns original stack if it is falsey', () => {
- const err = new Error('message')
+ it('adds docsUrl for app error and original error', () => {
+ err.docsUrl = 'https://on.cypress.io/orginal-error-docs-url'
- err.stack = ''
- const stack = $errUtils.replacedStack(err)
+ const result = $errUtils.createUncaughtException('app', err)
- expect(stack).to.equal('')
+ expect(result.docsUrl).to.eql([
+ 'https://on.cypress.io/uncaught-exception-from-application',
+ 'https://on.cypress.io/orginal-error-docs-url',
+ ])
})
+ })
+
+ context('Error.captureStackTrace', () => {
+ it('works - even where not natively support', () => {
+ function removeMe2 () {
+ const err = {}
- it('replaces stack in error with new stack', () => {
- const err = new Error('message')
- const newStackErr = new Error('different')
+ Error.captureStackTrace(err, removeMeAndAbove)
+
+ return err
+ }
+ function removeMe1 () {
+ return removeMe2()
+ }
+ function removeMeAndAbove () {
+ return removeMe1()
+ }
+ function dontRemoveMe () {
+ return removeMeAndAbove()
+ }
- newStackErr.stack = 'new stack'
- const stack = $errUtils.replacedStack(err, newStackErr)
+ const stack = dontRemoveMe().stack
- expect(stack).to.equal('Error: message\nnew stack')
+ expect(stack).to.include('dontRemoveMe')
+ expect(stack).not.to.include('removeMe1')
+ expect(stack).not.to.include('removeMe2')
+ expect(stack).not.to.include('removeMeAndAbove')
})
})
})
diff --git a/packages/driver/test/cypress/integration/cypress/network_utils_spec.js b/packages/driver/test/cypress/integration/cypress/network_utils_spec.js
new file mode 100644
index 000000000000..8dd3a182c522
--- /dev/null
+++ b/packages/driver/test/cypress/integration/cypress/network_utils_spec.js
@@ -0,0 +1,48 @@
+const $networkUtils = require('../../../../src/cypress/network_utils')
+
+describe('src/cypress/network_utils', () => {
+ context('#fetch', () => {
+ let xhr
+ let win
+
+ beforeEach(() => {
+ xhr = {
+ open: cy.stub(),
+ send: cy.stub(),
+ }
+
+ win = {
+ XMLHttpRequest: cy.stub().returns(xhr),
+ }
+ })
+
+ it('fetches the resource via XHR', () => {
+ $networkUtils.fetch('some/resource', win)
+ expect(win.XMLHttpRequest).to.be.called
+ expect(xhr.open).to.be.calledWith('GET', 'some/resource')
+ expect(xhr.send).to.be.called
+ })
+
+ it('resolves the promise with the response text when it loads', () => {
+ const getResource = $networkUtils.fetch('some/resource', win)
+
+ expect(xhr.onload).to.be.a('function')
+ xhr.onload.apply({ responseText: 'the response text' })
+
+ return getResource.then((result) => {
+ expect(result).to.equal('the response text')
+ })
+ })
+
+ it('rejects the promise when it errors', () => {
+ const getResource = $networkUtils.fetch('some/resource', win)
+
+ expect(xhr.onerror).to.be.a('function')
+ xhr.onerror.apply({ responseText: 'the response text' })
+
+ return getResource.catch((err) => {
+ expect(err.message).to.equal('Fetching resource at \'some/resource\' failed')
+ })
+ })
+ })
+})
diff --git a/packages/driver/test/cypress/integration/cypress/script_utils_spec.js b/packages/driver/test/cypress/integration/cypress/script_utils_spec.js
new file mode 100644
index 000000000000..510da977d6ec
--- /dev/null
+++ b/packages/driver/test/cypress/integration/cypress/script_utils_spec.js
@@ -0,0 +1,50 @@
+const $scriptUtils = require('../../../../src/cypress/script_utils')
+const $networkUtils = require('../../../../src/cypress/network_utils')
+const $sourceMapUtils = require('../../../../src/cypress/source_map_utils')
+
+describe('src/cypress/script_utils', () => {
+ context('#runScripts', () => {
+ let scriptWindow
+ const scripts = [
+ { relativeUrl: 'cypress/integration/script1.js' },
+ { relativeUrl: 'cypress/integration/script2.js' },
+ ]
+
+ beforeEach(() => {
+ scriptWindow = {
+ eval: cy.stub(),
+ __onscriptIframeReady: cy.stub(),
+ }
+
+ cy.stub($networkUtils, 'fetch').resolves('the script contents')
+ cy.stub($sourceMapUtils, 'extractSourceMap').resolves()
+ })
+
+ it('fetches each script', () => {
+ return $scriptUtils.runScripts(scriptWindow, scripts)
+ .then(() => {
+ expect($networkUtils.fetch).to.be.calledTwice
+ expect($networkUtils.fetch).to.be.calledWith(scripts[0].relativeUrl)
+ expect($networkUtils.fetch).to.be.calledWith(scripts[1].relativeUrl)
+ })
+ })
+
+ it('extracts the source map from each script', () => {
+ return $scriptUtils.runScripts(scriptWindow, scripts)
+ .then(() => {
+ expect($sourceMapUtils.extractSourceMap).to.be.calledTwice
+ expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[0], 'the script contents')
+ expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[1], 'the script contents')
+ })
+ })
+
+ it('evals each script', () => {
+ return $scriptUtils.runScripts(scriptWindow, scripts)
+ .then(() => {
+ expect($sourceMapUtils.extractSourceMap).to.be.calledTwice
+ expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[0], 'the script contents')
+ expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[1], 'the script contents')
+ })
+ })
+ })
+})
diff --git a/packages/driver/test/cypress/integration/cypress/source_map_utils_spec.js b/packages/driver/test/cypress/integration/cypress/source_map_utils_spec.js
new file mode 100644
index 000000000000..41a41a2d83da
--- /dev/null
+++ b/packages/driver/test/cypress/integration/cypress/source_map_utils_spec.js
@@ -0,0 +1,102 @@
+const { SourceMapConsumer } = require('source-map')
+import { extractSourceMap, getSourceContents, getSourcePosition } from '../../../../src/cypress/source_map_utils'
+
+const _ = Cypress._
+const Promise = Cypress.Promise
+
+const testContent = `it(\'simple test\', () => {
+ expect(true).to.be.true
+ expect(true).to.be.false
+ expect(false).to.be.false
+})
+`
+const sourceMap = {
+ version: 3,
+ sources: [
+ 'node_modules/browser-pack/_prelude.js',
+ 'cypress/integration/file1.js',
+ ],
+ names: [],
+ mappings: 'AAAA;;;ACAA,EAAE,CAAC,kBAAD,EAAqB,YAAM;AAC3B,EAAA,MAAM,CAAC,IAAD,CAAN,CAAa,EAAb,CAAgB,EAAhB,CAAmB,IAAnB;AACA,EAAA,MAAM,CAAC,IAAD,CAAN,CAAa,EAAb,CAAgB,EAAhB,CAAmB,KAAnB;AACA,EAAA,MAAM,CAAC,KAAD,CAAN,CAAc,EAAd,CAAiB,EAAjB,CAAoB,KAApB;AACD,CAJC,CAAF',
+ file: 'generated.js',
+ sourceRoot: '',
+ sourcesContent: [
+ '(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module \'"+o+"\'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o {
+ const base = `cypress/integration/${fileName}.js`
+
+ return {
+ fullyQualifiedUrl: `http://localhost:1234/tests?p=${base}`,
+ relativeUrl: `/tests?p=${base}`,
+ relative: base,
+ }
+}
+const file1 = createFile('file1')
+const file2 = createFile('file2')
+
+describe('driver/src/cypress/source_map_utils', () => {
+ context('.extractSourceMap', () => {
+ it('initializes and returns source map consumer and file', () => {
+ cy.spy(SourceMapConsumer, 'initialize')
+
+ return extractSourceMap(file1, fileContents).then((consumer) => {
+ expect(SourceMapConsumer.initialize).to.be.called
+ expect(consumer).to.be.an.instanceof(SourceMapConsumer)
+ })
+ })
+
+ it('resolves null if there is no source map embedded', () => {
+ return extractSourceMap(file2, testContent).then((consumer) => {
+ expect(consumer).to.be.null
+ })
+ })
+
+ it('resolves null if it is not an inline map', () => {
+ return extractSourceMap(file2, `${testContent}\n\/\/# sourceMappingURL=foo.map`).then((consumer) => {
+ expect(consumer).to.be.null
+ })
+ })
+ })
+
+ context('.getSourceContents', () => {
+ before(() => {
+ return Promise.join(
+ extractSourceMap(file1, fileContents),
+ extractSourceMap(file2, testContent),
+ )
+ })
+
+ it('provides source contents for given file', () => {
+ const contents = getSourceContents(file1.fullyQualifiedUrl, file1.relative)
+
+ expect(contents).to.equal(testContent)
+ })
+
+ it('returns null if no source map consumer can be found', () => {
+ expect(getSourceContents('does/not/exist', file1.relative)).to.be.null
+ })
+
+ it('returns null if file does not have source map', () => {
+ expect(getSourceContents(file2.fullyQualifiedUrl, file1.relative)).to.be.null
+ })
+ })
+
+ context('.getSourcePosition', () => {
+ before(() => {
+ return extractSourceMap(file1, fileContents)
+ })
+
+ it('returns source position for generated position', () => {
+ const position = getSourcePosition(file1.fullyQualifiedUrl, { line: 1, column: 2 })
+
+ expect(_.pick(position, 'line', 'column')).to.eql({ line: 1, column: 0 })
+ })
+ })
+})
diff --git a/packages/driver/test/cypress/integration/cypress/stack_utils_spec.js b/packages/driver/test/cypress/integration/cypress/stack_utils_spec.js
new file mode 100644
index 000000000000..edab9644d2ca
--- /dev/null
+++ b/packages/driver/test/cypress/integration/cypress/stack_utils_spec.js
@@ -0,0 +1,291 @@
+const $stackUtils = require('../../../../src/cypress/stack_utils')
+const $sourceMapUtils = require('../../../../src/cypress/source_map_utils')
+
+describe('driver/src/cypress/stack_utils', () => {
+ context('.replacedStack', () => {
+ const message = 'Original error\n\nline 2'
+
+ it('returns stack with original message', () => {
+ const err = new Error(message)
+ const newStack = 'at foo (path/to/file.js:1:1)\nat bar (path/to/file.js:2:2)'
+ const stack = $stackUtils.replacedStack(err, newStack)
+
+ expect(stack).to.equal(`Error: ${message}\n${newStack}`)
+ })
+
+ it('does not replace stack if error has no stack', () => {
+ const err = new Error(message)
+
+ err.stack = ''
+ const stack = $stackUtils.replacedStack(err, 'new stack')
+
+ expect(stack).to.equal('')
+ })
+ })
+
+ context('.getCodeFrame', () => {
+ let originalErr
+ const sourceCode = `it('is a failing test', () => {
+ cy.get('.not-there')
+})\
+`
+
+ beforeEach(() => {
+ originalErr = {
+ parsedStack: [
+ { message: 'Only a message' },
+ {
+ fileUrl: 'http://localhost:12345/__cypress/tests?p=cypress/integration/features/source_map_spec.js',
+ absoluteFile: '/dev/app/cypress/integration/features/source_map_spec.js',
+ relativeFile: 'cypress/integration/features/source_map_spec.js',
+ line: 2,
+ column: 5,
+ },
+ ],
+ }
+ })
+
+ it('returns existing code frame if error already has one', () => {
+ const existingCodeFrame = {}
+
+ originalErr.codeFrame = existingCodeFrame
+
+ expect($stackUtils.getCodeFrame(originalErr)).to.equal(existingCodeFrame)
+ })
+
+ it('returns undefined if there is no parsed stack', () => {
+ originalErr.parsedStack = undefined
+
+ expect($stackUtils.getCodeFrame(originalErr)).to.be.undefined
+ })
+
+ it('returns undefined if parsed stack is empty', () => {
+ originalErr.parsedStack = []
+
+ expect($stackUtils.getCodeFrame(originalErr)).to.be.undefined
+ })
+
+ it('returns undefined if there are only message lines', () => {
+ originalErr.parsedStack = [{ message: 'Only a message' }]
+
+ expect($stackUtils.getCodeFrame(originalErr)).to.be.undefined
+ })
+
+ it('returns code frame from first stack line', () => {
+ cy.stub($sourceMapUtils, 'getSourceContents').returns(sourceCode)
+
+ const codeFrame = $stackUtils.getCodeFrame(originalErr)
+
+ expect(codeFrame).to.be.an('object')
+ expect(codeFrame.frame).to.contain(` 1 | it('is a failing test', () => {`)
+ expect(codeFrame.frame).to.contain(`> 2 | cy.get('.not-there'`)
+ expect(codeFrame.frame).to.contain(` | ^`)
+ expect(codeFrame.frame).to.contain(` 3 | }`)
+ expect(codeFrame.absoluteFile).to.equal('/dev/app/cypress/integration/features/source_map_spec.js')
+ expect(codeFrame.relativeFile).to.equal('cypress/integration/features/source_map_spec.js')
+ expect(codeFrame.language).to.equal('js')
+ expect(codeFrame.line).to.equal(2)
+ expect(codeFrame.column).to.eq(5)
+ })
+
+ it('does not add code frame if stack does not yield one', () => {
+ cy.stub($sourceMapUtils, 'getSourceContents').returns(null)
+
+ expect($stackUtils.getCodeFrame(originalErr)).to.be.undefined
+ })
+ })
+
+ context('.getSourceStack', () => {
+ let generatedStack
+ const projectRoot = '/dev/app'
+
+ beforeEach(() => {
+ cy.stub($sourceMapUtils, 'getSourcePosition').returns({
+ file: 'some_other_file.ts',
+ line: 2,
+ column: 1,
+ })
+
+ $sourceMapUtils.getSourcePosition.onCall(1).returns({
+ file: 'cypress/integration/features/source_map_spec.coffee',
+ line: 4,
+ column: 3,
+ })
+
+ generatedStack = `Error: spec iframe stack
+ at foo.bar (http://localhost:1234/source_map_spec.js:12:4)
+ at Context. (http://localhost:1234/tests?p=cypress/integration/features/source_map_spec.js:6:4)\
+`
+ })
+
+ it('receives generated stack and returns object with source stack and parsed source stack', () => {
+ const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot)
+
+ expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack
+ at foo.bar (some_other_file.ts:2:2)
+ at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\
+`)
+
+ expect(sourceStack.parsed).to.eql([
+ {
+ message: 'Error: spec iframe stack',
+ whitespace: '',
+ },
+ {
+ function: 'foo.bar',
+ fileUrl: 'http://localhost:1234/source_map_spec.js',
+ relativeFile: 'some_other_file.ts',
+ absoluteFile: '/dev/app/some_other_file.ts',
+ line: 2,
+ column: 2,
+ whitespace: ' ',
+ },
+ {
+ function: 'Context.',
+ fileUrl: 'http://localhost:1234/tests?p=cypress/integration/features/source_map_spec.js',
+ relativeFile: 'cypress/integration/features/source_map_spec.coffee',
+ absoluteFile: '/dev/app/cypress/integration/features/source_map_spec.coffee',
+ line: 4,
+ column: 4,
+ whitespace: ' ',
+ },
+ ])
+ })
+
+ it('works when first line is the error message', () => {
+ const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot)
+
+ expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack
+ at foo.bar (some_other_file.ts:2:2)
+ at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\
+`)
+ })
+
+ it('works when first line is not the error message', () => {
+ generatedStack = generatedStack.split('\n').slice(1).join('\n')
+ const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot)
+
+ expect(sourceStack.sourceMapped).to.equal(` at foo.bar (some_other_file.ts:2:2)
+ at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\
+`)
+ })
+
+ it('works when first several lines are the error message', () => {
+ generatedStack = `Some\nmore\nlines\n\n${generatedStack}`
+ const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot)
+
+ expect(sourceStack.sourceMapped).to.equal(`Some
+more
+lines
+
+Error: spec iframe stack
+ at foo.bar (some_other_file.ts:2:2)
+ at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\
+`)
+ })
+
+ it('returns empty object if there\'s no stack', () => {
+ expect($stackUtils.getSourceStack()).to.eql({})
+ })
+ })
+
+ context('.stackWithUserInvocationStackSpliced', () => {
+ let err
+ let userInvocationStack
+
+ beforeEach(() => {
+ err = new Error(`\
+original message
+original message line 2
+original message line 3`)
+
+ err.stack = `\
+Error: original message
+original message line 2
+original message line 3
+ at originalStack1 (path/to/file:1:1)
+ at originalStack2 (path/to/file:1:1)
+ at __stackReplacementMarker (path/to/another:2:2)
+ at originalStack4 (path/to/file:1:1)
+ at originalStack5 (path/to/file:1:1)`
+
+ userInvocationStack = `\
+user invocation message
+user invocation message line 2
+user invocation message line 3
+ at userStack1 (path/to/another:2:2)
+ at userStack2 (path/to/another:2:2)`
+ })
+
+ it('appends replaces the user invocation wrapper and all lines below it with the user invocation stack', () => {
+ const { stack } = $stackUtils.stackWithUserInvocationStackSpliced(err, userInvocationStack)
+
+ expect(stack).to.equal(`\
+Error: original message
+original message line 2
+original message line 3
+ at originalStack1 (path/to/file:1:1)
+ at originalStack2 (path/to/file:1:1)
+From Your Spec Code:
+ at userStack1 (path/to/another:2:2)
+ at userStack2 (path/to/another:2:2)`)
+ })
+
+ it('returns the index of where the user invocation is in the stack', () => {
+ const { index } = $stackUtils.stackWithUserInvocationStackSpliced(err, userInvocationStack)
+
+ expect(index).to.equal(6)
+ })
+
+ it('appends at end when there is no stack replacement marker in the stack', () => {
+ err.stack = err.stack.replace(' at __stackReplacementMarker (path/to/another:2:2)\n', '')
+
+ const { stack } = $stackUtils.stackWithUserInvocationStackSpliced(err, userInvocationStack)
+
+ expect(stack).to.equal(`\
+Error: original message
+original message line 2
+original message line 3
+ at originalStack1 (path/to/file:1:1)
+ at originalStack2 (path/to/file:1:1)
+ at originalStack4 (path/to/file:1:1)
+ at originalStack5 (path/to/file:1:1)
+From Your Spec Code:
+ at userStack1 (path/to/another:2:2)
+ at userStack2 (path/to/another:2:2)`)
+ })
+ })
+
+ context('.stackWithoutMessage', () => {
+ it('returns stack with the foremost message lines', () => {
+ const stack = `\
+message 1
+message 2
+ at stack1 (foo.js:1:1)
+message 3
+ at stack2 (bar.js:2:2)`
+ const result = $stackUtils.stackWithoutMessage(stack)
+
+ expect(result).to.equal(`\
+ at stack1 (foo.js:1:1)
+message 3
+ at stack2 (bar.js:2:2)`)
+ })
+ })
+
+ context('.normalizedUserInvocationStack', () => {
+ it('removes message and cy[name] lines and normalizes indentation', () => {
+ const stack = `\
+message 1
+message 2
+ at addCommand/cy[name]@cypress:///cy.js:0:0
+ at stack1 (foo.js:1:1)
+ at stack2 (bar.js:2:2)`
+ const result = $stackUtils.normalizedUserInvocationStack(stack)
+
+ expect(result).to.equal(`\
+ at stack1 (foo.js:1:1)
+ at stack2 (bar.js:2:2)`)
+ })
+ })
+})
diff --git a/packages/driver/test/cypress/integration/cypress/utils_spec.coffee b/packages/driver/test/cypress/integration/cypress/utils_spec.coffee
index 647418a2ee10..6acba2525193 100644
--- a/packages/driver/test/cypress/integration/cypress/utils_spec.coffee
+++ b/packages/driver/test/cypress/integration/cypress/utils_spec.coffee
@@ -127,3 +127,24 @@ describe "driver/src/cypress/utils", ->
memoizedFn("input-1")
## cache for input-1 is cleared, so it calls the function again
expect(fn.callCount).to.be.equal(4)
+
+ context ".indent", ->
+ it "indents each line by the given amount", ->
+ str = "line 1\n line 2\n line 3\n line 4\n line 5"
+
+ expect($utils.indent(str, 3)).to.equal(" line 1\n line 2\n line 3\n line 4\n line 5")
+
+ context ".normalizeNewLines", ->
+ it "removes newlines in excess of max newlines, replacing with max newlines by default", ->
+ oneLineResult = $utils.normalizeNewLines("one new line\ntwo new lines\n\nthree new lines\n\n\nend", 1)
+ twoLinesResult = $utils.normalizeNewLines("one new line\ntwo new lines\n\nthree new lines\n\n\nend", 2)
+
+ expect(oneLineResult).to.equal("one new line\ntwo new lines\nthree new lines\nend")
+ expect(twoLinesResult).to.equal("one new line\ntwo new lines\n\nthree new lines\n\nend")
+
+ it "replaces with specified newlines", ->
+ oneLineResult = $utils.normalizeNewLines("one new line\ntwo new lines\n\nthree new lines\n\n\nend", 1, 2)
+ twoLinesResult = $utils.normalizeNewLines("one new line\ntwo new lines\n\nthree new lines\n\n\nend", 2, 1)
+
+ expect(oneLineResult).to.equal("one new line\n\ntwo new lines\n\nthree new lines\n\nend")
+ expect(twoLinesResult).to.equal("one new line\ntwo new lines\nthree new lines\nend")
diff --git a/packages/driver/test/cypress/integration/e2e/uncaught_errors_spec.coffee b/packages/driver/test/cypress/integration/e2e/uncaught_errors_spec.coffee
index 8763657308b7..19aa70f07790 100644
--- a/packages/driver/test/cypress/integration/e2e/uncaught_errors_spec.coffee
+++ b/packages/driver/test/cypress/integration/e2e/uncaught_errors_spec.coffee
@@ -36,11 +36,11 @@ describe "uncaught errors", ->
cy.on "uncaught:exception", (err, runnable) ->
try
- expect(err.name).to.eq("Uncaught ReferenceError")
+ expect(err.name).to.eq("ReferenceError")
expect(err.message).to.include("foo is not defined")
- expect(err.message).to.include("This error originated from your application code, not from Cypress.")
+ expect(err.message).to.include("The following error originated from your application code, not from Cypress.")
expect(err.message).to.not.include("https://on.cypress.io/uncaught-exception-from-application")
- expect(err.docsUrl).to.eq("https://on.cypress.io/uncaught-exception-from-application")
+ expect(err.docsUrl).to.eql(["https://on.cypress.io/uncaught-exception-from-application"])
expect(runnable is r).to.be.true
return false
catch err2
diff --git a/packages/driver/test/cypress/plugins/index.js b/packages/driver/test/cypress/plugins/index.js
index b0a17637cffb..41cef2835853 100644
--- a/packages/driver/test/cypress/plugins/index.js
+++ b/packages/driver/test/cypress/plugins/index.js
@@ -10,6 +10,15 @@ const webpack = require('@cypress/webpack-preprocessor')
process.env.NO_LIVERELOAD = '1'
const webpackOptions = require('@packages/runner/webpack.config.ts').default
+const babelLoader = _.find(webpackOptions.module.rules, (rule) => {
+ return _.includes(rule.use.loader, 'babel-loader')
+})
+
+// get rid of prismjs plugin. the driver doesn't need it
+babelLoader.use.options.plugins = _.reject(babelLoader.use.options.plugins, (plugin) => {
+ return _.includes(plugin[0], 'babel-plugin-prismjs')
+})
+
/**
* @type {Cypress.PluginConfig}
*/
diff --git a/packages/driver/test/cypress/support/defaults.coffee b/packages/driver/test/cypress/support/defaults.coffee
index a53bebd7564d..53155ecfb709 100644
--- a/packages/driver/test/cypress/support/defaults.coffee
+++ b/packages/driver/test/cypress/support/defaults.coffee
@@ -8,13 +8,16 @@ beforeEach ->
## restore it before each test
Cypress.config(ORIG_CONFIG)
+ isActuallyInteractive = Cypress.config("isInteractive")
## always set that we're interactive so we
## get consistent passes and failures when running
## from CI and when running in GUI mode
Cypress.config("isInteractive", true)
- ## necessary or else snapshots will not be taken
- ## and we can't test them
- Cypress.config("numTestsKeptInMemory", 1)
+
+ if not isActuallyInteractive
+ ## necessary or else snapshots will not be taken
+ ## and we can't test them
+ Cypress.config("numTestsKeptInMemory", 1)
## remove all event listeners
## from the window
diff --git a/packages/reporter/README.md b/packages/reporter/README.md
index e2fce75c5078..0361c44d05ce 100644
--- a/packages/reporter/README.md
+++ b/packages/reporter/README.md
@@ -66,4 +66,3 @@ Run enzyme component tests found in `*.spec` files in `src`:
```bash
yarn lerna run test --scope @packages/reporter --stream
```
-
diff --git a/packages/reporter/cypress/fixtures/aliases_runnables.json b/packages/reporter/cypress/fixtures/runnables_aliases.json
similarity index 100%
rename from packages/reporter/cypress/fixtures/aliases_runnables.json
rename to packages/reporter/cypress/fixtures/runnables_aliases.json
diff --git a/packages/reporter/cypress/fixtures/errors_runnables.json b/packages/reporter/cypress/fixtures/runnables_error.json
similarity index 100%
rename from packages/reporter/cypress/fixtures/errors_runnables.json
rename to packages/reporter/cypress/fixtures/runnables_error.json
diff --git a/packages/reporter/cypress/integration/aliases_spec.coffee b/packages/reporter/cypress/integration/aliases_spec.coffee
index f061fe77a91a..f5c59040f4c3 100644
--- a/packages/reporter/cypress/integration/aliases_spec.coffee
+++ b/packages/reporter/cypress/integration/aliases_spec.coffee
@@ -19,7 +19,7 @@ addLog = (runner, log) ->
describe "aliases", ->
context "route aliases", ->
beforeEach ->
- cy.fixture("aliases_runnables").as("runnables")
+ cy.fixture("runnables_aliases").as("runnables")
@runner = new EventEmitter()
@@ -233,7 +233,7 @@ describe "aliases", ->
context "element aliases", ->
beforeEach ->
- cy.fixture("aliases_runnables").as("runnables")
+ cy.fixture("runnables_aliases").as("runnables")
@runner = new EventEmitter()
@@ -438,4 +438,3 @@ describe "aliases", ->
cy.get(".cy-tooltip span").should ($tooltip) ->
expect($tooltip).to.contain("Found an alias for: 'dropdown'")
-
diff --git a/packages/reporter/cypress/integration/test_errors_spec.js b/packages/reporter/cypress/integration/test_errors_spec.js
index a80ef92cf3ab..ece0f4814bf2 100644
--- a/packages/reporter/cypress/integration/test_errors_spec.js
+++ b/packages/reporter/cypress/integration/test_errors_spec.js
@@ -1,13 +1,139 @@
const { EventEmitter } = require('events')
+const _ = Cypress._
+
+const itHandlesFileOpening = (containerSelector) => {
+ beforeEach(function () {
+ cy.stub(this.runner, 'emit').callThrough()
+ this.setError(this.commandErr)
+ })
+
+ describe('when user has already set opener and opens file', function () {
+ beforeEach(function () {
+ this.editor = {}
+
+ this.runner.emit.withArgs('get:user:editor').yields({
+ preferredOpener: this.editor,
+ })
+
+ cy.contains('View stack trace').click()
+ })
+
+ it('opens in preferred opener', function () {
+ cy.get(`${containerSelector} a`).first().click().then(() => {
+ expect(this.runner.emit).to.be.calledWith('open:file', {
+ where: this.editor,
+ file: '/me/dev/my/app.js',
+ line: 2,
+ column: 7,
+ })
+ })
+ })
+ })
+
+ describe('when user has not already set opener and opens file', function () {
+ const availableEditors = [
+ { id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' },
+ { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' },
+ { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' },
+ { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' },
+ { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' },
+ { id: 'other', name: 'Other', isOther: true, openerId: '' },
+ ]
+
+ beforeEach(function () {
+ this.runner.emit.withArgs('get:user:editor').yields({ availableEditors })
+ // usual viewport of only reporter is a bit cramped for the modal
+ cy.viewport(600, 600)
+ cy.contains('View stack trace').click()
+ cy.get(`${containerSelector} a`).first().click()
+ })
+
+ it('opens modal with available editors', function () {
+ _.each(availableEditors, ({ name }) => {
+ cy.contains(name)
+ })
+
+ cy.contains('Other')
+ cy.contains('Set preference and open file')
+ })
+
+ // NOTE: this fails because mobx doesn't make the editors observable, so
+ // the changes to the path don't bubble up correctly. this only happens
+ // in the Cypress test and not when running the actual app
+ it.skip('updates "Other" path when typed into', function () {
+ cy.contains('Other').find('input[type="text"]').type('/path/to/editor')
+ .should('have.value', '/path/to/editor')
+ })
+
+ describe('when editor is not selected', function () {
+ it('disables submit button', function () {
+ cy.contains('Set preference and open file')
+ .should('have.class', 'is-disabled')
+ .click()
+
+ cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
+ cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
+ })
+
+ it('shows validation message when hovering over submit button', function () {
+ cy.get('.editor-picker-modal .submit').trigger('mouseover')
+ cy.get('.cy-tooltip').should('have.text', 'Please select a preference')
+ })
+ })
+
+ describe('when Other is selected but path is not entered', function () {
+ beforeEach(function () {
+ cy.contains('Other').click()
+ })
+
+ it('disables submit button', function () {
+ cy.contains('Set preference and open file')
+ .should('have.class', 'is-disabled')
+ .click()
+
+ cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
+ cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
+ })
+
+ it('shows validation message when hovering over submit button', function () {
+ cy.get('.editor-picker-modal .submit').trigger('mouseover')
+ cy.get('.cy-tooltip').should('have.text', 'Please enter the path for the "Other" editor')
+ })
+ })
+
+ describe('when editor is set', function () {
+ beforeEach(function () {
+ cy.contains('Visual Studio Code').click()
+ cy.contains('Set preference and open file').click()
+ })
+
+ it('closes modal', function () {
+ cy.contains('Set preference and open file').should('not.be.visible')
+ })
+
+ it('emits set:user:editor', function () {
+ expect(this.runner.emit).to.be.calledWith('set:user:editor', availableEditors[4])
+ })
+
+ it('opens file in selected editor', function () {
+ expect(this.runner.emit).to.be.calledWith('open:file', {
+ where: availableEditors[4],
+ file: '/me/dev/my/app.js',
+ line: 2,
+ column: 7,
+ })
+ })
+ })
+ })
+}
describe('test errors', function () {
beforeEach(function () {
- cy.fixture('errors_runnables').as('runnablesErr')
+ cy.fixture('runnables_error').as('runnablesErr')
this.commandErr = {
name: 'CommandError',
message: '`foo` \\`bar\\` **baz** *fizz* ** buzz **',
- mdMessage: '`cy.check()` can only be called on `:checkbox` and `:radio`. Your subject contains a: ``',
stack: `Some Error
at foo.bar (my/app.js:2:7)
at baz.qux (cypress/integration/foo_spec.js:5:2)
@@ -16,6 +142,13 @@ describe('test errors', function () {
`,
parsedStack: [{
message: 'Some Error',
+ whitespace: '',
+ }, {
+ message: '',
+ whitespace: '',
+ }, {
+ message: 'Message line below blank line',
+ whitespace: ' ',
}, {
relativeFile: 'my/app.js',
absoluteFile: '/me/dev/my/app.js',
@@ -40,6 +173,37 @@ describe('test errors', function () {
line: 8,
column: 11,
whitespace: ' ',
+ }, {
+ relativeFile: 'cypress://../driver/src/cypress/runner.js',
+ absoluteFile: 'cypress://../driver/src/cypress/runner.js',
+ function: 'callFn',
+ line: 9,
+ column: 12,
+ whitespace: ' ',
+ }, {
+ relativeFile: 'http://localhost:12345/__cypress/runner/cypress_runner.js',
+ absoluteFile: 'http://localhost:12345/__cypress/runner/cypress_runner.js',
+ function: 'throwErr',
+ line: 10,
+ column: 13,
+ whitespace: ' ',
+ }, {
+ message: 'From Node.js Internals:',
+ whitespace: ' ',
+ }, {
+ relativeFile: 'events.js',
+ absoluteFile: 'events.js',
+ function: 'emit',
+ line: 11,
+ column: 14,
+ whitespace: ' ',
+ }, {
+ relativeFile: 'some/node/internals.js',
+ absoluteFile: '/user/path/to/node/some/node/internals.js',
+ function: 'writeFile',
+ line: 12,
+ column: 15,
+ whitespace: ' ',
}],
docsUrl: 'https://on.cypress.io/type',
codeFrame: {
@@ -117,10 +281,70 @@ describe('test errors', function () {
cy.get('.runnable-err-stack-trace').should('be.visible')
})
+ it('pairs down stack line whitespace', function () {
+ cy.contains('View stack trace').click()
+
+ cy.get('.runnable-err-stack-trace').within(() => {
+ cy.get('.err-stack-line')
+ .should('have.length', 9)
+ .first().should('have.text', 'at foo.bar (my/app.js:2:7)')
+
+ cy.get('.err-stack-line')
+ .eq(1).should('have.text', ' at baz.qux (cypress/integration/foo_spec.js:5:2)')
+
+ cy.get('.err-stack-line')
+ .eq(2).should('have.text', 'At previous event:')
+
+ cy.get('.err-stack-line')
+ .eq(3).should('have.text', ' at bar.baz (my/app.js:8:11)')
+
+ cy.get('.err-stack-line')
+ .eq(4).should('have.text', ' at callFn (cypress://../driver/src/cypress/runner.js:9:12)')
+ })
+ })
+
+ it('does not include message in stack trace', function () {
+ cy.contains('View stack trace').click()
+ cy.get('.runnable-err-stack-trace')
+ .invoke('text')
+ .should('not.include', 'Some Error')
+ .should('not.include', 'Message line below blank line')
+ })
+
+ it('turns files into links', function () {
+ cy.contains('View stack trace').click()
+
+ cy.get('.runnable-err-stack-trace .runnable-err-file-path')
+ .should('have.length', 3)
+ .first()
+ .should('have.text', 'my/app.js:2:7')
+
+ cy.get('.runnable-err-stack-trace .runnable-err-file-path').eq(1)
+ .should('have.text', 'cypress/integration/foo_spec.js:5:2')
+ })
+
+ it('does not turn cypress:// files into links', function () {
+ cy.contains('View stack trace').click()
+ cy.contains('cypress://').find('a').should('not.exist')
+ })
+
+ it('does not turn cypress_runner.js files into links', function () {
+ cy.contains('View stack trace').click()
+ cy.contains('cypress_runner.js').find('a').should('not.exist')
+ })
+
+ it('does not turn anything after "From Node.js Internals" into links', function () {
+ cy.contains('View stack trace').click()
+ cy.contains('events.js').find('a').should('not.exist')
+ cy.contains('node/internals.js').find('a').should('not.exist')
+ })
+
it('does not collapse test when clicking', () => {
cy.contains('View stack trace').click()
cy.get('.command-wrapper').should('be.visible')
})
+
+ itHandlesFileOpening('.runnable-err-stack-trace')
})
describe('command error', function () {
@@ -170,4 +394,42 @@ describe('test errors', function () {
.and('not.contain', '`foo`')
})
})
+
+ describe('code frames', function () {
+ it('shows code frame when included on error', function () {
+ this.setError(this.commandErr)
+
+ cy
+ .get('.test-err-code-frame')
+ .should('be.visible')
+ })
+
+ it('does not show code frame when not included on error', function () {
+ this.commandErr.codeFrame = undefined
+ this.setError(this.commandErr)
+
+ cy
+ .get('.test-err-code-frame')
+ .should('not.be.visible')
+ })
+
+ it('use correct language class', function () {
+ this.setError(this.commandErr)
+
+ cy
+ .get('.test-err-code-frame pre')
+ .should('have.class', 'language-javascript')
+ })
+
+ it('falls back to text language class', function () {
+ this.commandErr.codeFrame.language = null
+ this.setError(this.commandErr)
+
+ cy
+ .get('.test-err-code-frame pre')
+ .should('have.class', 'language-text')
+ })
+
+ itHandlesFileOpening('.test-err-code-frame')
+ })
})
diff --git a/packages/reporter/package.json b/packages/reporter/package.json
index d5aeb4fa7036..34d88ba8965a 100644
--- a/packages/reporter/package.json
+++ b/packages/reporter/package.json
@@ -20,6 +20,8 @@
"@packages/driver": "*",
"@packages/socket": "*",
"@packages/web-config": "*",
+ "@reach/dialog": "0.6.1",
+ "@reach/visually-hidden": "0.6.1",
"@types/chai-enzyme": "0.6.7",
"chai": "3.5.0",
"chai-enzyme": "1.0.0-beta.1",
@@ -34,6 +36,7 @@
"mobx": "5.15.4",
"mobx-react": "6.1.8",
"mocha": "6.2.2",
+ "prismjs": "1.16.0",
"prop-types": "15.7.2",
"react": "16.12.0",
"react-dom": "16.12.0",
diff --git a/packages/reporter/src/.eslintrc.json b/packages/reporter/src/.eslintrc.json
index 98cd7c48fa41..debcccc97d3a 100644
--- a/packages/reporter/src/.eslintrc.json
+++ b/packages/reporter/src/.eslintrc.json
@@ -4,7 +4,13 @@
"plugin:@cypress/dev/tests"
],
"parser": "@typescript-eslint/parser",
+ "settings": {
+ "react": {
+ "version": "16.12"
+ }
+ },
"rules": {
+ "arrow-body-style": "off",
"no-unused-vars": "off",
"react/jsx-filename-extension": [
"warn",
diff --git a/packages/reporter/src/errors/editor-picker-modal.tsx b/packages/reporter/src/errors/editor-picker-modal.tsx
new file mode 100644
index 000000000000..a7f5da2947fa
--- /dev/null
+++ b/packages/reporter/src/errors/editor-picker-modal.tsx
@@ -0,0 +1,102 @@
+import _ from 'lodash'
+import { Dialog } from '@reach/dialog'
+import { action } from 'mobx'
+import { observer, useLocalStore } from 'mobx-react'
+// @ts-ignore
+import Tooltip from '@cypress/react-tooltip'
+
+import cs from 'classnames'
+import React from 'react'
+import VisuallyHidden from '@reach/visually-hidden'
+// @ts-ignore
+import { EditorPicker } from '@packages/ui-components'
+
+export interface Editor {
+ id: string
+ name: string
+ openerId: string
+ isOther: boolean
+}
+
+interface Props {
+ chosenEditor: Editor
+ editors: Editor[]
+ isOpen: boolean
+ onClose: (() => void)
+ onSetChosenEditor: ((editor: Editor) => void)
+ onSetEditor: ((editor: Editor) => void)
+}
+
+const validate = (chosenEditor: Editor) => {
+ let isValid = !!chosenEditor && !!chosenEditor.id
+ let validationMessage = 'Please select a preference'
+
+ if (isValid && chosenEditor.isOther && !chosenEditor.openerId) {
+ isValid = false
+ validationMessage = 'Please enter the path for the "Other" editor'
+ }
+
+ return {
+ isValid,
+ validationMessage,
+ }
+}
+
+const EditorPickerModal = observer(({ chosenEditor, editors, isOpen, onClose, onSetChosenEditor, onSetEditor }: Props) => {
+ const state = useLocalStore((external) => ({
+ setOtherPath: action((otherPath: string) => {
+ const otherOption = _.find(external.editors, { isOther: true })
+
+ if (otherOption) {
+ otherOption.openerId = otherPath
+ }
+ }),
+ }), { editors })
+
+ const setEditor = () => {
+ const { isValid } = validate(chosenEditor)
+
+ if (!isValid) return
+
+ onSetEditor(chosenEditor)
+ }
+
+ if (!editors.length) return null
+
+ const { isValid, validationMessage } = validate(chosenEditor)
+
+ return (
+
+
+
File Opener Preference
+
Please select your preference for opening files on your system.
+
+
We will use your selected preference to open files in the future. You can change your preference in the Settings tab of the Cypress Test Runner.
+
+
+
+ Set preference and open file
+
+ Cancel
+
+
+ Close
+
+
+
+
+
+ )
+})
+
+export default EditorPickerModal
diff --git a/packages/reporter/src/errors/err-model.spec.ts b/packages/reporter/src/errors/err-model.spec.ts
index ebbc60a3209a..fdf7bb2e66aa 100644
--- a/packages/reporter/src/errors/err-model.spec.ts
+++ b/packages/reporter/src/errors/err-model.spec.ts
@@ -2,16 +2,22 @@ import Err from './err-model'
describe('Err model', () => {
context('.displayMessage', () => {
- it('returns combo of name and mdMessage', () => {
- const err = new Err({ name: 'BadError', mdMessage: 'Something went poorly', message: 'Something went wrong' })
+ it('returns combo of name and message', () => {
+ const err = new Err({ name: 'BadError', message: 'Something went wrong' })
- expect(err.displayMessage).to.equal('BadError: Something went poorly')
+ expect(err.displayMessage).to.equal('BadError: Something went wrong')
})
- it('returns combo of name and message if no mdMessage', () => {
- const err = new Err({ name: 'BadError', message: 'Something went wrong' })
+ it('returns name if no message', () => {
+ const err = new Err({ name: 'BadError' })
- expect(err.displayMessage).to.equal('BadError: Something went wrong')
+ expect(err.displayMessage).to.equal('BadError')
+ })
+
+ it('returns message if no name', () => {
+ const err = new Err({ message: 'Something went wrong' })
+
+ expect(err.displayMessage).to.equal('Something went wrong')
})
it('returns empty string if no name or message', () => {
diff --git a/packages/reporter/src/errors/err-model.ts b/packages/reporter/src/errors/err-model.ts
index 7767b53f673c..0824ed7ec8b0 100644
--- a/packages/reporter/src/errors/err-model.ts
+++ b/packages/reporter/src/errors/err-model.ts
@@ -1,46 +1,76 @@
+/* eslint-disable padding-line-between-statements */
+import _ from 'lodash'
import { computed, observable } from 'mobx'
+export interface FileDetails {
+ absoluteFile: string
+ column: number
+ line: number
+ relativeFile: string
+}
+
+interface ParsedStackMessageLine {
+ message: string
+ whitespace: string
+}
+
+interface ParsedStackFileLine extends FileDetails {
+ fileUrl: string
+ function: string
+ whitespace: string
+}
+
+type ParsedStackLine = ParsedStackMessageLine & ParsedStackFileLine
+
+export interface CodeFrame extends FileDetails {
+ frame: string
+ language: string
+}
+
export interface ErrProps {
- name?: string
- message?: string
- mdMessage?: string
- stack?: string
- docsUrl?: string
+ name: string
+ message: string
+ stack: string
+ sourceMappedStack: string
+ parsedStack: ParsedStackLine[]
+ docsUrl: string | string[]
+ templateType: string
+ codeFrame: CodeFrame
}
export default class Err {
@observable name = ''
@observable message = ''
@observable stack = ''
- @observable mdMessage = ''
- @observable docsUrl = ''
+ @observable sourceMappedStack = ''
+ @observable.ref parsedStack = [] as ParsedStackLine[]
+ @observable docsUrl = '' as string | string[]
+ @observable templateType = ''
+ // @ts-ignore
+ @observable.ref codeFrame: CodeFrame
- constructor (props?: ErrProps) {
+ constructor (props?: Partial) {
this.update(props)
}
@computed get displayMessage () {
- if (!this.name && !this.mdMessage) return ''
-
- return `${this.name}: ${this.mdMessage}`
+ return _.compact([this.name, this.message]).join(': ')
}
@computed get isCommandErr () {
return /(AssertionError|CypressError)/.test(this.name)
}
- update (props?: ErrProps) {
+ update (props?: Partial) {
if (!props) return
if (props.name) this.name = props.name
-
if (props.message) this.message = props.message
-
- // @ts-ignore
- if (props.mdMessage || props.message) this.mdMessage = props.mdMessage || props.message
-
if (props.stack) this.stack = props.stack
-
if (props.docsUrl) this.docsUrl = props.docsUrl
+ if (props.sourceMappedStack) this.sourceMappedStack = props.sourceMappedStack
+ if (props.parsedStack) this.parsedStack = props.parsedStack
+ if (props.templateType) this.templateType = props.templateType
+ if (props.codeFrame) this.codeFrame = props.codeFrame
}
}
diff --git a/packages/reporter/src/errors/error-code-frame.tsx b/packages/reporter/src/errors/error-code-frame.tsx
new file mode 100644
index 000000000000..8b27244cb3b4
--- /dev/null
+++ b/packages/reporter/src/errors/error-code-frame.tsx
@@ -0,0 +1,36 @@
+import React, { Component } from 'react'
+import { observer } from 'mobx-react'
+import Prism from 'prismjs'
+
+import { CodeFrame } from './err-model'
+import ErrorFilePath from './error-file-path'
+
+interface Props {
+ codeFrame: CodeFrame
+}
+
+@observer
+class ErrorCodeFrame extends Component {
+ componentDidMount () {
+ Prism.highlightAllUnder(this.refs.codeFrame as ParentNode)
+ }
+
+ render () {
+ const { line, frame, language } = this.props.codeFrame
+
+ // since we pull out 2 lines above the highlighted code, it will always
+ // be the 3rd line unless it's at the top of the file (lines 1 or 2)
+ const highlightLine = Math.min(line, 3)
+
+ return (
+
+ )
+ }
+}
+
+export default ErrorCodeFrame
diff --git a/packages/reporter/src/errors/error-file-path.tsx b/packages/reporter/src/errors/error-file-path.tsx
new file mode 100644
index 000000000000..3bef272442de
--- /dev/null
+++ b/packages/reporter/src/errors/error-file-path.tsx
@@ -0,0 +1,97 @@
+import _ from 'lodash'
+import { action } from 'mobx'
+import { observer, useLocalStore } from 'mobx-react'
+import React, { MouseEvent } from 'react'
+// @ts-ignore
+import Tooltip from '@cypress/react-tooltip'
+// @ts-ignore
+import { EditorPicker } from '@packages/ui-components'
+
+import EditorPickerModal, { Editor } from './editor-picker-modal'
+import { FileDetails } from './err-model'
+import events from '../lib/events'
+
+interface GetUserEditorResult {
+ preferredOpener?: Editor
+ availableEditors?: Editor[]
+}
+
+interface Props {
+ fileDetails: FileDetails
+}
+
+const openFile = (where: Editor, { absoluteFile: file, line, column }: FileDetails) => {
+ events.emit('open:file', {
+ where,
+ file,
+ line,
+ column,
+ })
+}
+
+const ErrorFilePath = observer(({ fileDetails }: Props) => {
+ const state = useLocalStore(() => ({
+ editors: [] as Editor[],
+ chosenEditor: {} as Editor,
+ isLoadingEditor: false,
+ isModalOpen: false,
+ setChosenEditor: action((editor: Editor) => {
+ state.chosenEditor = editor
+ }),
+ setEditors: action((editors: Editor[]) => {
+ state.editors = editors
+ }),
+ setIsLoadingEditor: action((isLoading: boolean) => {
+ state.isLoadingEditor = isLoading
+ }),
+ setIsModalOpen: action((isOpen: boolean) => {
+ state.isModalOpen = isOpen
+ }),
+ }))
+
+ const attemptOpenFile = (e: MouseEvent) => {
+ e.preventDefault()
+
+ if (state.isLoadingEditor) return
+
+ state.setIsLoadingEditor(true)
+
+ // TODO: instead of the back-n-forth, send 'open:file' or similar, and if the
+ // user editor isn't set, it should send back the available editors
+ events.emit('get:user:editor', (result: GetUserEditorResult) => {
+ state.setIsLoadingEditor(false)
+
+ if (result.preferredOpener) {
+ return openFile(result.preferredOpener, fileDetails)
+ }
+
+ state.setEditors(result.availableEditors || [])
+ state.setIsModalOpen(true)
+ })
+ }
+
+ const setEditor = (editor: Editor) => {
+ events.emit('set:user:editor', editor)
+ state.setIsModalOpen(false)
+ state.setChosenEditor({} as Editor)
+ openFile(editor, fileDetails)
+ }
+
+ const { relativeFile, line, column } = fileDetails
+
+ return (
+
+ {relativeFile}:{line}:{column}
+
+
+ )
+})
+
+export default ErrorFilePath
diff --git a/packages/reporter/src/errors/error-stack.tsx b/packages/reporter/src/errors/error-stack.tsx
new file mode 100644
index 000000000000..e1f0612396a3
--- /dev/null
+++ b/packages/reporter/src/errors/error-stack.tsx
@@ -0,0 +1,74 @@
+import _ from 'lodash'
+import { observer } from 'mobx-react'
+import React, { ReactElement } from 'react'
+
+import ErrorFilePath from './error-file-path'
+import Err from './err-model'
+
+const cypressLineRegex = /(cypress:\/\/|cypress_runner\.js)/
+
+interface Props {
+ err: Err,
+}
+
+type StringOrElement = string | ReactElement
+
+const ErrorStack = observer(({ err }: Props) => {
+ if (!err.parsedStack) return <>err.stack>
+
+ // only display stack lines beyond the original message, since it's already
+ // displayed above this
+ let foundFirstStackLine = false
+ const stackLines = _.filter(err.parsedStack, ({ message }) => {
+ if (foundFirstStackLine) return true
+
+ if (message != null) return false
+
+ foundFirstStackLine = true
+
+ return true
+ })
+ // instead of having every line indented, get rid of the smallest amount of
+ // whitespace common to each line so the stack is aligned left but lines
+ // with extra whitespace still have it
+ const whitespaceLengths = _.map(stackLines, ({ whitespace }) => whitespace ? whitespace.length : 0)
+ const commonWhitespaceLength = Math.min(...whitespaceLengths)
+
+ const makeLine = (key: string, content: StringOrElement[]) => {
+ return (
+ {content}
+ )
+ }
+
+ let stopLinking = false
+ const lines = _.map(stackLines, (stackLine, index) => {
+ const { relativeFile, function: fn, line, column } = stackLine
+ const key = `${relativeFile}${index}`
+
+ const whitespace = stackLine.whitespace.slice(commonWhitespaceLength)
+
+ if (stackLine.message != null) {
+ // we append some errors with 'node internals', which we don't want to link
+ // so stop linking anything after 'From Node.js Internals'
+ if (stackLine.message.includes('From Node')) {
+ stopLinking = true
+ }
+
+ return makeLine(key, [whitespace, stackLine.message])
+ }
+
+ if (cypressLineRegex.test(relativeFile || '') || stopLinking) {
+ return makeLine(key, [whitespace, `at ${fn} (${relativeFile}:${line}:${column})`])
+ }
+
+ const link = (
+
+ )
+
+ return makeLine(key, [whitespace, `at ${fn} (`, link, ')'])
+ })
+
+ return <>{lines}>
+})
+
+export default ErrorStack
diff --git a/packages/reporter/src/errors/errors.scss b/packages/reporter/src/errors/errors.scss
index 3042af564dcc..d200aeba6315 100644
--- a/packages/reporter/src/errors/errors.scss
+++ b/packages/reporter/src/errors/errors.scss
@@ -178,6 +178,56 @@
.err-stack-line {
white-space: pre;
+
+ // ensure empty lines still take up vertical space
+ &:empty:before {
+ content: ' ';
+ }
+ }
+ }
+
+ .test-err-code-frame {
+ background-color: #fff;
+ border: 1px solid #ffe4e7;
+ margin: 10px;
+
+ .runnable-err-file-path {
+ display: block;
+ padding: 3px 10px 0;
+ line-height: 24px;
+ font-size: 13px;
+ position: relative;
+ top: 5px;
+ }
+
+ pre {
+ background-color: #fff;
+ border: 0;
+ padding-left: 10px;
}
}
}
+
+.editor-picker-modal {
+ max-width: 40em;
+
+ .editor-picker {
+ margin-bottom: 1em;
+ }
+
+ .controls {
+ > span:first-child {
+ order: 1;
+ }
+
+ button.is-disabled,
+ button.is-disabled:hover,
+ button.is-disabled:focus {
+ background: $pass !important;
+ cursor: default !important;
+ opacity: 0.5;
+ }
+
+ padding: 1em 1em 1em;
+ }
+}
diff --git a/packages/reporter/src/errors/prism.scss b/packages/reporter/src/errors/prism.scss
new file mode 100644
index 000000000000..9781032e9032
--- /dev/null
+++ b/packages/reporter/src/errors/prism.scss
@@ -0,0 +1,83 @@
+@import "../../node_modules/prismjs/themes/prism";
+@import "../../node_modules/prismjs/plugins/line-highlight/prism-line-highlight";
+
+code[class*="language-"],
+pre[class*="language-"] {
+ color: #70787a;
+ margin: 0;
+}
+
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+ background: #fff;
+}
+
+// .token.comment,
+// .token.prolog,
+// .token.doctype,
+// .token.cdata {
+// color: slategray;
+// }
+
+.token.punctuation {
+ color: #70787a;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+ color: #c1434f;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+ color: #469b76;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string,
+.token.regex,
+.token.important,
+.token.variable {
+ color: #e08a45;
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword,
+.token.function,
+.token.class-name {
+ color: #3e76ad;
+}
+
+.line-numbers .line-numbers-rows {
+ border-right: 0;
+}
+
+.line-numbers-rows > span:before {
+ color: #c4c8cb;
+}
+
+.line-highlight {
+ background: linear-gradient(to right, hsla(24, 20%, 50%,.1) 100%, hsla(24, 20%, 50%,0));
+
+ &:before {
+ display: none;
+ }
+}
+
+.test-error-code-frame pre[data-line] {
+ padding-left: 0.5em;
+ position: relative;
+}
diff --git a/packages/reporter/src/errors/test-error.tsx b/packages/reporter/src/errors/test-error.tsx
index f27f6f55412c..5b7222af8f27 100644
--- a/packages/reporter/src/errors/test-error.tsx
+++ b/packages/reporter/src/errors/test-error.tsx
@@ -6,15 +6,36 @@ import Markdown from 'markdown-it'
import Tooltip from '@cypress/react-tooltip'
import Collapsible from '../collapsible/collapsible'
+import ErrorCodeFrame from '../errors/error-code-frame'
+import ErrorStack from '../errors/error-stack'
import events from '../lib/events'
import TestModel from '../test/test-model'
-interface Props {
+interface DocsUrlProps {
+ url: string | string[]
+}
+
+const DocsUrl = ({ url }: DocsUrlProps) => {
+ if (!url) return null
+
+ const urlArray = _.castArray(url)
+
+ return (<>
+ {_.map(urlArray, (url) => (
+
+ Learn more
+
+
+ ))}
+ >)
+}
+
+interface TestErrorProps {
model: TestModel
}
-const TestError = observer((props: Props) => {
+const TestError = observer((props: TestErrorProps) => {
const md = new Markdown('zero')
md.enable(['backticks', 'emphasis', 'escape'])
@@ -30,6 +51,7 @@ const TestError = observer((props: Props) => {
}
const { err } = props.model
+ const { codeFrame } = err
if (!err.displayMessage) return null
@@ -50,12 +72,7 @@ const TestError = observer((props: Props) => {
{err.stack &&
@@ -64,9 +81,10 @@ const TestError = observer((props: Props) => {
headerClass='runnable-err-stack-expander'
contentClass='runnable-err-stack-trace'
>
- {err.stack}
+
}
+ {codeFrame && }
)
diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts
index bd0a3fbd2d1e..156dffe89894 100644
--- a/packages/reporter/src/lib/events.ts
+++ b/packages/reporter/src/lib/events.ts
@@ -190,6 +190,14 @@ const events: Events = {
runner.emit('focus:tests')
})
+ localBus.on('get:user:editor', (cb) => {
+ runner.emit('get:user:editor', cb)
+ })
+
+ localBus.on('set:user:editor', (editor) => {
+ runner.emit('set:user:editor', editor)
+ })
+
localBus.on('save:state', () => {
runner.emit('save:state', {
autoScrollingEnabled: appState.autoScrollingEnabled,
@@ -199,6 +207,10 @@ const events: Events = {
localBus.on('external:open', (url) => {
runner.emit('external:open', url)
})
+
+ localBus.on('open:file', (fileDetails) => {
+ runner.emit('open:file', fileDetails)
+ })
},
emit (event, ...args) {
diff --git a/packages/reporter/src/lib/modal.scss b/packages/reporter/src/lib/modal.scss
new file mode 100644
index 000000000000..6820e0d10ce0
--- /dev/null
+++ b/packages/reporter/src/lib/modal.scss
@@ -0,0 +1,78 @@
+[data-reach-dialog-overlay] {
+ display: flex;
+ z-index: 1;
+}
+
+[data-reach-dialog-content] {
+ align-items: center;
+ background: #f8f8f8;
+ border-radius: 10px;
+ font-family: $open-sans;
+ font-size: 0.9em;
+ justify-content: center;
+ margin: auto;
+ min-width: 30em;
+ padding: 2em 0 0;
+ position: relative;
+
+ h1 {
+ font-size: 1.5em;
+ padding-bottom: 0.5em;
+ }
+
+ button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1em;
+ padding: 0.6em 1em;
+ line-height: 1em;
+ }
+
+ p {
+ line-height: 1.5;
+ margin-bottom: 1em;
+ color: #444;
+ }
+
+ .content {
+ padding: 0 1.2em 0.6em;
+ }
+
+ .controls {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0.6em;
+
+ button {
+ border-radius: 3px;
+ }
+
+ .submit {
+ background: $pass;
+ color: #fff;
+ margin-left: 0.6em;
+ order: 1;
+
+ &:hover,
+ &:focus {
+ background: darken($pass, 10%);
+ }
+ }
+
+ .cancel {
+ background: #e3e3e3;
+
+ &:hover,
+ &:focus {
+ background: darken(#e3e3e3, 10%);
+ }
+ }
+ }
+
+ .close-button {
+ position: absolute;
+ right: 0.1em;
+ top: 0.5em;
+ }
+}
diff --git a/packages/reporter/src/main-runner.scss b/packages/reporter/src/main-runner.scss
index 60a47867aaa9..7b42199916c6 100644
--- a/packages/reporter/src/main-runner.scss
+++ b/packages/reporter/src/main-runner.scss
@@ -3,16 +3,7 @@
@import 'lib/variables';
@import 'lib/base';
@import 'lib/tooltip';
+@import 'lib/modal';
+@import '../../../node_modules/@reach/dialog/styles.css';
+@import '../../ui-components/src/editor-picker';
@import './!(lib)*/**/*';
-
-/* Used to provide additional context for screen readers */
-.visually-hidden {
- border: 0;
- clip: rect(0 0 0 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- width: 1px;
-}
diff --git a/packages/reporter/src/main.scss b/packages/reporter/src/main.scss
index bb5e78d22141..d9a966c06533 100644
--- a/packages/reporter/src/main.scss
+++ b/packages/reporter/src/main.scss
@@ -4,16 +4,7 @@
@import 'lib/fonts';
@import 'lib/base';
@import 'lib/tooltip';
+@import 'lib/modal';
+@import '../../../node_modules/@reach/dialog/styles.css';
+@import '../../ui-components/src/editor-picker';
@import '!(lib)*/**/*';
-
-/* Used to provide additional context for screen readers */
-.visually-hidden {
- border: 0;
- clip: rect(0 0 0 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- width: 1px;
-}
diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx
index 5e1bf4be0493..645959b3fcf8 100644
--- a/packages/reporter/src/test/test.tsx
+++ b/packages/reporter/src/test/test.tsx
@@ -96,7 +96,6 @@ class Test extends Component {
{this._contents()}
-
)
}
@@ -119,6 +118,7 @@ class Test extends Component {
{model.commands.length ? : }
+
)
}
diff --git a/packages/reporter/webpack.config.ts b/packages/reporter/webpack.config.ts
index c4217b731725..8084460f5f02 100644
--- a/packages/reporter/webpack.config.ts
+++ b/packages/reporter/webpack.config.ts
@@ -1,10 +1,10 @@
-// @ts-ignore
-import commonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
+import getCommonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
import path from 'path'
+import webpack from 'webpack'
-const config: typeof commonConfig = {
- // @ts-ignore
- ...commonConfig,
+// @ts-ignore
+const config: webpack.Configuration = {
+ ...getCommonConfig(),
entry: {
reporter: [path.resolve(__dirname, 'src')],
},
@@ -24,4 +24,15 @@ config.plugins = [
}),
]
+config.resolve = {
+ ...config.resolve,
+ alias: {
+ 'lodash': require.resolve('lodash'),
+ 'mobx': require.resolve('mobx'),
+ 'mobx-react': require.resolve('mobx-react'),
+ 'react': require.resolve('react'),
+ 'react-dom': require.resolve('react-dom'),
+ },
+}
+
export default config
diff --git a/packages/runner/package.json b/packages/runner/package.json
index 995ce437da74..aaf6b2e043db 100644
--- a/packages/runner/package.json
+++ b/packages/runner/package.json
@@ -22,6 +22,7 @@
"@packages/socket": "*",
"@packages/web-config": "*",
"ansi-to-html": "0.6.14",
+ "babel-plugin-prismjs": "1.0.2",
"bluebird": "3.5.3",
"chai": "4.2.0",
"chai-enzyme": "1.0.0-beta.1",
@@ -34,6 +35,7 @@
"mobx": "5.15.4",
"mobx-react": "6.1.8",
"mocha": "7.0.1",
+ "prismjs": "1.16.0",
"prop-types": "15.7.2",
"react": "16.12.0",
"react-dom": "16.12.0",
diff --git a/packages/runner/src/.eslintrc.json b/packages/runner/src/.eslintrc.json
index ac6f238303ff..5849f1c5bafe 100644
--- a/packages/runner/src/.eslintrc.json
+++ b/packages/runner/src/.eslintrc.json
@@ -2,5 +2,10 @@
"extends": [
"plugin:@cypress/dev/react",
"plugin:@cypress/dev/tests"
- ]
+ ],
+ "settings": {
+ "react": {
+ "version": "16.12"
+ }
+ }
}
diff --git a/packages/runner/src/iframe/iframes.jsx b/packages/runner/src/iframe/iframes.jsx
index 358cd3fdf413..f81831b39c3b 100644
--- a/packages/runner/src/iframe/iframes.jsx
+++ b/packages/runner/src/iframe/iframes.jsx
@@ -1,4 +1,3 @@
-import Promise from 'bluebird'
import cs from 'classnames'
import { action, autorun } from 'mobx'
import { observer } from 'mobx-react'
@@ -105,24 +104,15 @@ export default class Iframes extends Component {
this.props.eventManager.setup(config, specPath)
- this._loadIframes(specPath)
- .then(($autIframe) => {
- this.props.eventManager.initialize($autIframe, config)
- })
- }
+ const $autIframe = this._loadIframes(specPath)
- _loadSpecInIframe (iframe, specSrc) {
- return new Promise((resolve) => {
- iframe.prop('src', specSrc).one('load', resolve)
- })
+ this.props.eventManager.initialize($autIframe, config)
}
// jQuery is a better fit for managing these iframes, since they need to get
// wiped out and reset on re-runs and the snapshots are from dom we don't control
_loadIframes (specPath) {
- // TODO: config should have "iframeUrl": "/__cypress/iframes"
const specSrc = `/${this.props.config.namespace}/iframes/${specPath}`
-
const $container = $(this.refs.container).empty()
const $autIframe = this.autIframe.create(this.props.config).appendTo($container)
@@ -132,8 +122,9 @@ export default class Iframes extends Component {
if (this.props.config.spec.specType === 'component') {
// In mount mode we need to render something right from spec file
// So load application tests to the aut frame
- return this._loadSpecInIframe($autIframe, specSrc)
- .then(() => $autIframe)
+ $autIframe.prop('src', specSrc)
+
+ return $autIframe
}
const $specIframe = $('', {
@@ -141,8 +132,9 @@ export default class Iframes extends Component {
class: 'spec-iframe',
}).appendTo($container)
- return this._loadSpecInIframe($specIframe, specSrc)
- .then(() => $autIframe)
+ $specIframe.prop('src', specSrc)
+
+ return $autIframe
}
_toggleSnapshotHighlights = (snapshotProps) => {
diff --git a/packages/runner/src/lib/base.scss b/packages/runner/src/lib/base.scss
index 0cb5e27081f9..96614dcfb108 100644
--- a/packages/runner/src/lib/base.scss
+++ b/packages/runner/src/lib/base.scss
@@ -3,7 +3,7 @@ body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input
body {
background: #F6F6F6;
width: 100%;
- height: 100%;
+ height: 100vh;
}
.runner,
diff --git a/packages/runner/src/lib/event-manager.js b/packages/runner/src/lib/event-manager.js
index ac86f839aa44..19dee7b01c38 100644
--- a/packages/runner/src/lib/event-manager.js
+++ b/packages/runner/src/lib/event-manager.js
@@ -96,6 +96,14 @@ const eventManager = {
reporterBus.on('focus:tests', this.focusTests)
+ reporterBus.on('get:user:editor', (cb) => {
+ ws.emit('get:user:editor', cb)
+ })
+
+ reporterBus.on('set:user:editor', (editor) => {
+ ws.emit('set:user:editor', editor)
+ })
+
reporterBus.on('runner:restart', rerun)
function sendEventIfSnapshotProps (logId, event) {
@@ -146,6 +154,10 @@ const eventManager = {
ws.emit('external:open', url)
})
+ reporterBus.on('open:file', (url) => {
+ ws.emit('open:file', url)
+ })
+
const $window = $(window)
$window.on('hashchange', rerun)
@@ -197,40 +209,44 @@ const eventManager = {
initialize ($autIframe, config) {
performance.mark('initialize-start')
- Cypress.initialize($autIframe)
-
- // get the current runnable in case we reran mid-test due to a visit
- // to a new domain
- ws.emit('get:existing:run:state', (state = {}) => {
- const runnables = Cypress.normalizeAll(state.tests)
- const run = () => {
- performance.mark('initialize-end')
- performance.measure('initialize', 'initialize-start', 'initialize-end')
- this._runDriver(state)
- }
-
- reporterBus.emit('runnables:ready', runnables)
-
- if (state.numLogs) {
- Cypress.setNumLogs(state.numLogs)
- }
-
- if (state.startTime) {
- Cypress.setStartTime(state.startTime)
- }
-
- if (state.currentId) {
- // if we have a currentId it means
- // we need to tell the Cypress to skip
- // ahead to that test
- Cypress.resumeAtTest(state.currentId, state.emissions)
- }
-
- if (config.isTextTerminal && !state.currentId) {
- ws.emit('set:runnables', runnables, run)
- } else {
- run()
- }
+ return Cypress.initialize({
+ $autIframe,
+ onSpecReady: () => {
+ // get the current runnable in case we reran mid-test due to a visit
+ // to a new domain
+ ws.emit('get:existing:run:state', (state = {}) => {
+ const runnables = Cypress.normalizeAll(state.tests)
+ const run = () => {
+ performance.mark('initialize-end')
+ performance.measure('initialize', 'initialize-start', 'initialize-end')
+
+ this._runDriver(state)
+ }
+
+ reporterBus.emit('runnables:ready', runnables)
+
+ if (state.numLogs) {
+ Cypress.setNumLogs(state.numLogs)
+ }
+
+ if (state.startTime) {
+ Cypress.setStartTime(state.startTime)
+ }
+
+ if (state.currentId) {
+ // if we have a currentId it means
+ // we need to tell the Cypress to skip
+ // ahead to that test
+ Cypress.resumeAtTest(state.currentId, state.emissions)
+ }
+
+ if (config.isTextTerminal && !state.currentId) {
+ ws.emit('set:runnables', runnables, run)
+ } else {
+ run()
+ }
+ })
+ },
})
},
diff --git a/packages/runner/webpack.config.ts b/packages/runner/webpack.config.ts
index 2ceb3006d51b..76202d72c846 100644
--- a/packages/runner/webpack.config.ts
+++ b/packages/runner/webpack.config.ts
@@ -1,6 +1,23 @@
import _ from 'lodash'
-import commonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
+import getCommonConfig, { HtmlWebpackPlugin } from '@packages/web-config/webpack.config.base'
import path from 'path'
+import webpack from 'webpack'
+
+const commonConfig = getCommonConfig()
+
+// @ts-ignore
+const babelLoader = _.find(commonConfig.module.rules, (rule) => {
+ // @ts-ignore
+ return _.includes(rule.use.loader, 'babel-loader')
+})
+
+// @ts-ignore
+babelLoader.use.options.plugins.push([require.resolve('babel-plugin-prismjs'), {
+ 'languages': ['javascript', 'coffeescript', 'typescript', 'jsx', 'tsx'],
+ 'plugins': ['line-numbers', 'line-highlight'],
+ 'theme': 'default',
+ 'css': false,
+}])
let pngRule
// @ts-ignore
@@ -21,7 +38,8 @@ pngRule.use[0].options = {
publicPath: '/__cypress/runner/img/',
}
-const config: typeof commonConfig = {
+// @ts-ignore
+const config: webpack.Configuration = {
...commonConfig,
module: {
rules: [
diff --git a/packages/server/__snapshots__/1_async_timeouts_spec.coffee.js b/packages/server/__snapshots__/1_async_timeouts_spec.coffee.js
index 4961d973c99b..a1208d71fc4b 100644
--- a/packages/server/__snapshots__/1_async_timeouts_spec.coffee.js
+++ b/packages/server/__snapshots__/1_async_timeouts_spec.coffee.js
@@ -27,12 +27,12 @@ exports['e2e async timeouts / failing1'] = `
1) async
bar fails:
- Error: Timed out after \`100ms\`. The \`done()\` callback was never invoked!
+ CypressError: Timed out after \`100ms\`. The \`done()\` callback was never invoked!
[stack trace lines]
2) async
fails async after cypress command:
- Error: Timed out after \`100ms\`. The \`done()\` callback was never invoked!
+ CypressError: Timed out after \`100ms\`. The \`done()\` callback was never invoked!
[stack trace lines]
diff --git a/packages/server/__snapshots__/1_caught_uncaught_hook_errors_spec.coffee.js b/packages/server/__snapshots__/1_caught_uncaught_hook_errors_spec.coffee.js
index 42a6e85eb39f..5c66ddd225a4 100644
--- a/packages/server/__snapshots__/1_caught_uncaught_hook_errors_spec.coffee.js
+++ b/packages/server/__snapshots__/1_caught_uncaught_hook_errors_spec.coffee.js
@@ -144,9 +144,9 @@ exports['e2e caught and uncaught hooks errors failing2 1'] = `
1) s1b
"before each" hook for "t2b":
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -228,9 +228,9 @@ exports['e2e caught and uncaught hooks errors failing3 1'] = `
1 failing
1) "before each" hook for "t1c":
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -320,9 +320,9 @@ exports['e2e caught and uncaught hooks errors failing4 1'] = `
1) uncaught hook error should continue to fire all mocha events
s1
"before each" hook for "does not run":
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
diff --git a/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js b/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js
index 64d6c67899b8..86604934a271 100644
--- a/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js
+++ b/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js
@@ -82,9 +82,9 @@ exports['e2e commands outside of test / fails on failing assertions'] = `
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught AssertionError: expected true to be false
+ AssertionError: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > expected true to be false
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
@@ -164,7 +164,9 @@ exports['e2e commands outside of test / fails on cy commands'] = `
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught CypressError: Cannot call \`cy.viewport()\` outside a running test.
+ CypressError: The following error originated from your test code, not from Cypress.
+
+ > Cannot call \`cy.viewport()\` outside a running test.
This usually happens when you accidentally write commands outside an \`it(...)\` test.
@@ -172,8 +174,6 @@ If that is the case, just move these commands inside an \`it(...)\` test.
Check your test file for errors.
-This error originated from your test code, not from Cypress.
-
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
Cypress could not associate this error to any specific test.
diff --git a/packages/server/__snapshots__/1_deprecated_spec.ts.js b/packages/server/__snapshots__/1_deprecated_spec.ts.js
index c4b163643a6a..34d9bb5a3e72 100644
--- a/packages/server/__snapshots__/1_deprecated_spec.ts.js
+++ b/packages/server/__snapshots__/1_deprecated_spec.ts.js
@@ -310,7 +310,7 @@ exports['deprecated before:browser:launch args / displays errors thrown and abor
Running: app_spec.js (1 of 2)
Error thrown from plugins handler
Error: Error thrown from plugins handler
- [stack trace lines]
+ [stack trace lines]
`
exports['deprecated before:browser:launch args / displays promises rejected and aborts the run'] = `
@@ -332,5 +332,5 @@ exports['deprecated before:browser:launch args / displays promises rejected and
Running: app_spec.js (1 of 2)
Promise rejected from plugins handler
Error: Promise rejected from plugins handler
- [stack trace lines]
+ [stack trace lines]
`
diff --git a/packages/server/__snapshots__/3_js_error_handling_spec.coffee.js b/packages/server/__snapshots__/3_js_error_handling_spec.coffee.js
index 0095f4f632e9..97e1d7933210 100644
--- a/packages/server/__snapshots__/3_js_error_handling_spec.coffee.js
+++ b/packages/server/__snapshots__/3_js_error_handling_spec.coffee.js
@@ -38,9 +38,9 @@ exports['e2e js error handling / fails'] = `
1) s1
without an afterEach hook
t1:
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -52,9 +52,9 @@ https://on.cypress.io/uncaught-exception-from-application
2) s1
without an afterEach hook
t2:
- Uncaught ReferenceError: bar is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > bar is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -66,9 +66,9 @@ https://on.cypress.io/uncaught-exception-from-application
3) s1
with an afterEach hook
t4:
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your application code, not from Cypress.
-This error originated from your application code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
@@ -86,7 +86,9 @@ https://on.cypress.io/uncaught-exception-from-application
5) s1
cross origin script errors
explains where script errored:
- Uncaught Error: Script error.
+ CypressError: The following error originated from your application code, not from Cypress.
+
+ > Script error.
Cypress detected that an uncaught error was thrown from a cross origin script.
@@ -96,15 +98,13 @@ Check your Developer Tools Console for the actual error - it should be printed t
It's possible to enable debugging these scripts by adding the \`crossorigin\` attribute and setting a CORS header.
-https://on.cypress.io/cross-origin-script-error
-
-This error originated from your application code, not from Cypress.
-
When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
This behavior is configurable, and you can choose to turn this off by listening to the \`uncaught:exception\` event.
https://on.cypress.io/uncaught-exception-from-application
+
+https://on.cypress.io/cross-origin-script-error
[stack trace lines]
diff --git a/packages/server/__snapshots__/3_plugins_spec.js b/packages/server/__snapshots__/3_plugins_spec.js
index c2a0408c509c..2f7f1a52c40b 100644
--- a/packages/server/__snapshots__/3_plugins_spec.js
+++ b/packages/server/__snapshots__/3_plugins_spec.js
@@ -365,7 +365,7 @@ exports['e2e plugins fails when there is an async error inside an event handler
The following error was thrown by a plugin. We stopped running your tests because a plugin crashed. Please check your plugins file (\`/foo/bar/.projects/plugins-async-error/cypress/plugins/index.js\`)
Error: Async error from plugins file
- [stack trace lines]
+ [stack trace lines]
(Results)
@@ -415,5 +415,5 @@ The following are valid events:
- task
- after:screenshot
- [stack trace lines]
+ [stack trace lines]
`
diff --git a/packages/server/__snapshots__/4_request_spec.coffee.js b/packages/server/__snapshots__/4_request_spec.coffee.js
index e3022889b579..b3ffbd1b9c74 100644
--- a/packages/server/__snapshots__/4_request_spec.coffee.js
+++ b/packages/server/__snapshots__/4_request_spec.coffee.js
@@ -124,14 +124,13 @@ Common situations why this would fail:
- your web server isn't accessible
- you have weird network configuration settings on your computer
-The stack trace for this error is:
-
-RequestError: Error: connect ECONNREFUSED 127.0.0.1:16795
- [stack trace lines]
-
-
https://on.cypress.io/request
+ [stack trace lines]
+ From Node.js Internals:
+ RequestError: Error: connect ECONNREFUSED 127.0.0.1:16795
+ [stack trace lines]
+
diff --git a/packages/server/__snapshots__/4_return_value_spec.coffee.js b/packages/server/__snapshots__/4_return_value_spec.coffee.js
index 9fd72544faf4..506e3666d549 100644
--- a/packages/server/__snapshots__/4_return_value_spec.coffee.js
+++ b/packages/server/__snapshots__/4_return_value_spec.coffee.js
@@ -66,7 +66,7 @@ https://on.cypress.io/returning-value-and-commands-in-custom-command
Original mocha error:
Error: Resolution method is overspecified. Specify a callback *or* return a Promise; not both.
- [stack trace lines]
+ [stack trace lines]
diff --git a/packages/server/__snapshots__/5_cdp_spec.ts.js b/packages/server/__snapshots__/5_cdp_spec.ts.js
index 520e684265b8..966f4fb42f17 100644
--- a/packages/server/__snapshots__/5_cdp_spec.ts.js
+++ b/packages/server/__snapshots__/5_cdp_spec.ts.js
@@ -39,7 +39,7 @@ The CDP port requested was 7777.
Error details:
Error: connect ECONNREFUSED 127.0.0.1:7777
- [stack trace lines]
+ [stack trace lines]
`
@@ -69,7 +69,7 @@ exports['e2e cdp / handles disconnections as expected'] = `
There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.
Error: connect ECONNREFUSED 127.0.0.1:7777
- [stack trace lines]
+ [stack trace lines]
(Results)
diff --git a/packages/server/__snapshots__/5_spec_isolation_spec.coffee.js b/packages/server/__snapshots__/5_spec_isolation_spec.coffee.js
index 25cf4b0c2eb4..63d47a449962 100644
--- a/packages/server/__snapshots__/5_spec_isolation_spec.coffee.js
+++ b/packages/server/__snapshots__/5_spec_isolation_spec.coffee.js
@@ -76,7 +76,7 @@ exports['e2e spec isolation fails'] = {
],
"state": "failed",
"body": "function () { }",
- "stack": "Error: fail1\n\nBecause this error occurred during a `before each` hook we are skipping the remaining tests in the current suite: `beforeEach hooks`\n [stack trace lines]",
+ "stack": "Error: fail1\n\nBecause this error occurred during a `before each` hook we are skipping the remaining tests in the current suite: `beforeEach hooks`\n [stack trace lines]",
"error": "fail1\n\nBecause this error occurred during a `before each` hook we are skipping the remaining tests in the current suite: `beforeEach hooks`",
"timings": {
"lifecycle": 100,
@@ -126,7 +126,7 @@ exports['e2e spec isolation fails'] = {
],
"state": "failed",
"body": "function () { }",
- "stack": "Error: fail2\n\nBecause this error occurred during a `after each` hook we are skipping the remaining tests in the current suite: `afterEach hooks`\n [stack trace lines]",
+ "stack": "Error: fail2\n\nBecause this error occurred during a `after each` hook we are skipping the remaining tests in the current suite: `afterEach hooks`\n [stack trace lines]",
"error": "fail2\n\nBecause this error occurred during a `after each` hook we are skipping the remaining tests in the current suite: `afterEach hooks`",
"timings": {
"lifecycle": 100,
@@ -196,7 +196,7 @@ exports['e2e spec isolation fails'] = {
],
"state": "failed",
"body": "function () { }",
- "stack": "Error: fail3\n\nBecause this error occurred during a `after all` hook we are skipping the remaining tests in the current suite: `after hooks`\n [stack trace lines]",
+ "stack": "Error: fail3\n\nBecause this error occurred during a `after all` hook we are skipping the remaining tests in the current suite: `after hooks`\n [stack trace lines]",
"error": "fail3\n\nBecause this error occurred during a `after all` hook we are skipping the remaining tests in the current suite: `after hooks`",
"timings": {
"lifecycle": 100,
@@ -299,7 +299,7 @@ exports['e2e spec isolation fails'] = {
],
"state": "failed",
"body": "function () {\n return cy.wrap(true, {\n timeout: 100\n }).should(\"be.false\");\n }",
- "stack": "AssertionError: Timed out retrying: expected true to be false\n [stack trace lines]",
+ "stack": "AssertionError: Timed out retrying: expected true to be false\n [stack trace lines]",
"error": "Timed out retrying: expected true to be false",
"timings": {
"lifecycle": 100,
@@ -328,7 +328,7 @@ exports['e2e spec isolation fails'] = {
],
"state": "failed",
"body": "function () {\n throw new Error(\"fails2\");\n }",
- "stack": "Error: fails2\n [stack trace lines]",
+ "stack": "Error: fails2\n [stack trace lines]",
"error": "fails2",
"timings": {
"lifecycle": 100,
diff --git a/packages/server/__snapshots__/6_task_spec.coffee.js b/packages/server/__snapshots__/6_task_spec.coffee.js
index 76852bc63c54..fb5bb6d98d57 100644
--- a/packages/server/__snapshots__/6_task_spec.coffee.js
+++ b/packages/server/__snapshots__/6_task_spec.coffee.js
@@ -104,7 +104,7 @@ https://on.cypress.io/api/task
CypressError: \`cy.task('errors')\` failed with the following error:
> Error: Error thrown in task handler
- [stack trace lines]
+ [stack trace lines]
diff --git a/packages/server/__snapshots__/6_uncaught_spec_errors_spec.coffee.js b/packages/server/__snapshots__/6_uncaught_spec_errors_spec.coffee.js
index abc7413179df..97bdb2866a1e 100644
--- a/packages/server/__snapshots__/6_uncaught_spec_errors_spec.coffee.js
+++ b/packages/server/__snapshots__/6_uncaught_spec_errors_spec.coffee.js
@@ -23,9 +23,9 @@ exports['e2e uncaught errors / failing1'] = `
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
@@ -105,9 +105,9 @@ exports['e2e uncaught errors / failing2'] = `
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
@@ -182,33 +182,61 @@ exports['e2e uncaught errors / failing3'] = `
foo
- 1) bar
+ 1) fails with setTimeout
+ 2) fails with setTimeout and done
+ ✓ passes with fail handler after failing with setTimeout
+ 3) fails with async app code error
+ ✓ passes with fail handler after failing with async app code error
+ - fails with promise
- 0 passing
- 1 failing
+ 2 passing
+ 1 pending
+ 3 failing
1) foo
- bar:
- Uncaught ReferenceError: foo is not defined
+ fails with setTimeout:
+ ReferenceError: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
[stack trace lines]
+ 2) foo
+ fails with setTimeout and done:
+ ReferenceError: The following error originated from your test code, not from Cypress.
+
+ > foo is not defined
+
+When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
+ [stack trace lines]
+
+ 3) foo
+ fails with async app code error:
+ ReferenceError: The following error originated from your application code, not from Cypress.
+
+ > qax is not defined
+
+When Cypress detects uncaught errors originating from your application it will automatically fail the current test.
+
+This behavior is configurable, and you can choose to turn this off by listening to the \`uncaught:exception\` event.
+
+https://on.cypress.io/uncaught-exception-from-application
+ [stack trace lines]
+
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
- │ Tests: 1 │
- │ Passing: 0 │
- │ Failing: 1 │
- │ Pending: 0 │
+ │ Tests: 6 │
+ │ Passing: 2 │
+ │ Failing: 3 │
+ │ Pending: 1 │
│ Skipped: 0 │
- │ Screenshots: 1 │
+ │ Screenshots: 3 │
│ Video: true │
│ Duration: X seconds │
│ Spec Ran: uncaught_during_test_spec.coffee │
@@ -217,8 +245,12 @@ When Cypress detects uncaught errors originating from your test code it will aut
(Screenshots)
- - /XXX/XXX/XXX/cypress/screenshots/uncaught_during_test_spec.coffee/foo -- bar (fa (1280x720)
- iled).png
+ - /XXX/XXX/XXX/cypress/screenshots/uncaught_during_test_spec.coffee/foo -- fails w (1280x720)
+ ith setTimeout (failed).png
+ - /XXX/XXX/XXX/cypress/screenshots/uncaught_during_test_spec.coffee/foo -- fails w (1280x720)
+ ith setTimeout and done (failed).png
+ - /XXX/XXX/XXX/cypress/screenshots/uncaught_during_test_spec.coffee/foo -- fails w (1280x720)
+ ith async app code error (failed).png
(Video)
@@ -235,9 +267,9 @@ When Cypress detects uncaught errors originating from your test code it will aut
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
- │ ✖ uncaught_during_test_spec.coffee XX:XX 1 - 1 - - │
+ │ ✖ uncaught_during_test_spec.coffee XX:XX 6 2 3 1 - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
- ✖ 1 of 1 failed (100%) XX:XX 1 - 1 - -
+ ✖ 1 of 1 failed (100%) XX:XX 6 2 3 1 -
`
@@ -273,9 +305,9 @@ exports['e2e uncaught errors / failing4'] = `
1) foo
"before all" hook for "does not run":
- Uncaught ReferenceError: foo is not defined
+ ReferenceError: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > foo is not defined
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
diff --git a/packages/server/__snapshots__/6_uncaught_support_file_spec.coffee.js b/packages/server/__snapshots__/6_uncaught_support_file_spec.coffee.js
index 0a46e561e1f9..33f31a7008c7 100644
--- a/packages/server/__snapshots__/6_uncaught_support_file_spec.coffee.js
+++ b/packages/server/__snapshots__/6_uncaught_support_file_spec.coffee.js
@@ -22,9 +22,9 @@ exports['e2e uncaught support file errors failing 1'] = `
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught Error: bar
+ Error: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > bar
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
diff --git a/packages/server/__snapshots__/6_visit_spec.coffee.js b/packages/server/__snapshots__/6_visit_spec.coffee.js
index e7ca9ff92962..4b6fe6d8d3a1 100644
--- a/packages/server/__snapshots__/6_visit_spec.coffee.js
+++ b/packages/server/__snapshots__/6_visit_spec.coffee.js
@@ -118,13 +118,12 @@ Common situations why this would fail:
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
-The stack trace for this error is:
-
-Error: connect ECONNREFUSED 127.0.0.1:16795
- [stack trace lines]
-
+ [stack trace lines]
+ From Node.js Internals:
+ Error: connect ECONNREFUSED 127.0.0.1:16795
+ [stack trace lines]
+
@@ -581,13 +580,12 @@ Common situations why this would fail:
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
-The stack trace for this error is:
-
-Error: ESOCKETTIMEDOUT
- [stack trace lines]
-
+ [stack trace lines]
+ From Node.js Internals:
+ Error: ESOCKETTIMEDOUT
+ [stack trace lines]
+
2) response timeouts result in an error
handles no response errors when not initially visiting:
@@ -606,13 +604,12 @@ Common situations why this would fail:
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
-The stack trace for this error is:
-
-Error: ESOCKETTIMEDOUT
- [stack trace lines]
-
+ [stack trace lines]
+ From Node.js Internals:
+ Error: ESOCKETTIMEDOUT
+ [stack trace lines]
+
3) response timeouts result in an error
fails after reducing the responseTimeout option:
@@ -631,13 +628,12 @@ Common situations why this would fail:
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
-The stack trace for this error is:
-
-Error: ESOCKETTIMEDOUT
- [stack trace lines]
-
+ [stack trace lines]
+ From Node.js Internals:
+ Error: ESOCKETTIMEDOUT
+ [stack trace lines]
+
diff --git a/packages/server/__snapshots__/7_record_spec.coffee.js b/packages/server/__snapshots__/7_record_spec.coffee.js
index a09dee7d4309..a729b1e1e86a 100644
--- a/packages/server/__snapshots__/7_record_spec.coffee.js
+++ b/packages/server/__snapshots__/7_record_spec.coffee.js
@@ -169,9 +169,9 @@ Because this error occurred during a \`before each\` hook we are skipping the re
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught Error: instantly fails
+ Error: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > instantly fails
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
@@ -1054,9 +1054,9 @@ Because this error occurred during a \`before each\` hook we are skipping the re
1 failing
1) An uncaught error was detected outside of a test:
- Uncaught Error: instantly fails
+ Error: The following error originated from your test code, not from Cypress.
-This error originated from your test code, not from Cypress.
+ > instantly fails
When Cypress detects uncaught errors originating from your test code it will automatically fail the current test.
diff --git a/packages/server/__snapshots__/8_network_error_handling_spec.coffee.js b/packages/server/__snapshots__/8_network_error_handling_spec.coffee.js
index 8a51c87c88dc..3c10aaff005e 100644
--- a/packages/server/__snapshots__/8_network_error_handling_spec.coffee.js
+++ b/packages/server/__snapshots__/8_network_error_handling_spec.coffee.js
@@ -382,13 +382,12 @@ Common situations why this would fail:
- you forgot to run / boot your web server
- your web server isn't accessible
- you have weird network configuration settings on your computer
-
-The stack trace for this error is:
-
-Error: socket hang up
- [stack trace lines]
-
+ [stack trace lines]
+ From Node.js Internals:
+ Error: socket hang up
+ [stack trace lines]
+
2) network error handling
cy.request() retries
@@ -418,14 +417,13 @@ Common situations why this would fail:
- your web server isn't accessible
- you have weird network configuration settings on your computer
-The stack trace for this error is:
-
-RequestError: Error: socket hang up
- [stack trace lines]
-
-
https://on.cypress.io/request
+ [stack trace lines]
+ From Node.js Internals:
+ RequestError: Error: socket hang up
+ [stack trace lines]
+
diff --git a/packages/server/__snapshots__/8_reporters_spec.coffee.js b/packages/server/__snapshots__/8_reporters_spec.coffee.js
index cd7eea1f1b63..61ea2bd0a317 100644
--- a/packages/server/__snapshots__/8_reporters_spec.coffee.js
+++ b/packages/server/__snapshots__/8_reporters_spec.coffee.js
@@ -685,7 +685,7 @@ We searched for the reporter in these paths:
The error we received was:
Error: this reporter threw an error
- [stack trace lines]
+ [stack trace lines]
Learn more at https://on.cypress.io/reporters
diff --git a/packages/server/__snapshots__/snapshot_spec.coffee.js b/packages/server/__snapshots__/snapshot_spec.coffee.js
new file mode 100644
index 000000000000..a9eeff9490d6
--- /dev/null
+++ b/packages/server/__snapshots__/snapshot_spec.coffee.js
@@ -0,0 +1,5 @@
+exports['has backticks'] = `
+line 1
+line 2 with \`42\`
+line 3 with \`foo\`
+`
diff --git a/packages/server/lib/controllers/files.coffee b/packages/server/lib/controllers/files.coffee
index 2dc3b681a883..114e48d001ed 100644
--- a/packages/server/lib/controllers/files.coffee
+++ b/packages/server/lib/controllers/files.coffee
@@ -23,28 +23,18 @@ module.exports = {
handleIframe: (req, res, config, getRemoteState) ->
test = req.params[0]
- debug("handle iframe %o", { test })
-
iframePath = cwd("lib", "html", "iframe.html")
+ debug("handle iframe %o", { test })
+
@getSpecs(test, config)
.then (specs) =>
- escapeSpecFilename = (fileName) =>
- fileName = fileName.replace(SPEC_URL_PREFIX, "__CYPRESS_SPEC_URL_PREFIX__")
-
- return escapeFilenameInUrl(fileName).replace("__CYPRESS_SPEC_URL_PREFIX__", SPEC_URL_PREFIX)
-
- specs = specs.map(escapeSpecFilename)
- debug("escaped spec filenames %o", specs)
-
@getJavascripts(config)
.then (js) =>
res.render iframePath, {
- title: @getTitle(test)
- domain: getRemoteState().domainName
- # stylesheets: @getStylesheets(config)
- javascripts: js
- specs: specs
+ title: @getTitle(test)
+ domain: getRemoteState().domainName
+ scripts: JSON.stringify(js.concat(specs))
}
getSpecs: (spec, config) ->
@@ -86,13 +76,17 @@ module.exports = {
getSpecsHelper()
prepareForBrowser: (filePath, projectRoot) ->
+ filePath = filePath.replace(SPEC_URL_PREFIX, "__CYPRESS_SPEC_URL_PREFIX__")
+ filePath = escapeFilenameInUrl(filePath).replace("__CYPRESS_SPEC_URL_PREFIX__", SPEC_URL_PREFIX)
relativeFilePath = path.relative(projectRoot, filePath)
- debug("from file path %s got relative path %s to project root", filePath, relativeFilePath)
- @getTestUrl(relativeFilePath)
+ {
+ absolute: filePath
+ relative: relativeFilePath
+ relativeUrl: @getTestUrl(relativeFilePath)
+ }
getTestUrl: (file) ->
- file += CacheBuster.get()
url = "#{SPEC_URL_PREFIX}=#{file}"
debug("test url for file %o", {file, url})
return url
diff --git a/packages/server/lib/controllers/runner.coffee b/packages/server/lib/controllers/runner.coffee
index e2f2c6107e6f..af08055e9e85 100644
--- a/packages/server/lib/controllers/runner.coffee
+++ b/packages/server/lib/controllers/runner.coffee
@@ -51,6 +51,12 @@ module.exports = {
handle: (req, res) ->
pathToFile = runner.getPathToDist(req.params[0])
+ send(req, pathToFile)
+ .pipe(res)
+
+ handleSourceMappings: (req, res) ->
+ pathToFile = runner.getPathToSourceMappings()
+
send(req, pathToFile)
.pipe(res)
}
diff --git a/packages/server/lib/gui/events.coffee b/packages/server/lib/gui/events.coffee
index a1e4e485cc79..461947484376 100644
--- a/packages/server/lib/gui/events.coffee
+++ b/packages/server/lib/gui/events.coffee
@@ -20,6 +20,7 @@ ensureUrl = require("../util/ensure-url")
chromePolicyCheck = require("../util/chrome_policy_check")
browsers = require("../browsers")
konfig = require("../konfig")
+editors = require("../util/editors")
nullifyUnserializableValues = (obj) =>
## nullify values that cannot be cloned
@@ -267,6 +268,16 @@ handleEvent = (options, bus, event, id, type, arg) ->
.then(send)
.catch(sendErr)
+ when "get:user:editor"
+ editors.getUserEditor(true)
+ .then(send)
+ .catch(sendErr)
+
+ when "set:user:editor"
+ editors.setUserEditor(arg)
+ .then(send)
+ .catch(sendErr)
+
when "get:specs"
openProject.getSpecChanges({
onChange: send
diff --git a/packages/server/lib/html/iframe.html b/packages/server/lib/html/iframe.html
index 35fcfe497642..d7e5e08728bb 100644
--- a/packages/server/lib/html/iframe.html
+++ b/packages/server/lib/html/iframe.html
@@ -13,16 +13,8 @@
if (!Cypress) {
throw new Error("Tests cannot run without a reference to Cypress!");
}
- return Cypress.onSpecWindow(window);
+ return Cypress.onSpecWindow(window, {{scripts | safe}});
})(window.opener || window.parent);
-
- {{each(options.javascripts)}}
-
- {{/each}}
-
- {{each(options.specs)}}
-
- {{/each}}