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.

+
+
+ + + + +
+ +
+ ) +}) + +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 ( +
+ +
+          {frame}
+        
+
+ ) + } +} + +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.docsUrl && - - Learn more - - - } +
{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 = $('