Split hooks and open hooks in ide from reporter (#7821)

* Add invocation details to hooks

* Add hooks to emitted root runnable

* Hook details model

* Progress on hook models

* Add commands to hook

* Fix display of hooks

* Display hooks in correct order

* (More) efficiently reorder hooks and display split numbers

* Open hook in IDE functionality

* Properly style button to open in IDE

* Add ability to open test body in IDE

* Fix hooks specs

* Runnables store tests

* Test model unit tests

* HookDetails -> HookProps

* Fix reporter integration tests

* Fix issue with after hook

* Update runner mocha tests

* Remove driver integration test that is no longer needed

* Update snapshot for server tests

* Add reporter integration tests for hooks

* Fix driver querying test

* Add runner test for hooks

* Update reporter hook tests to check for before all

* Fix before/after hook counts

* Fix runner test

* Fix issue with stack trace in ff and clean up mocha override

* Just incase someone names their test or support file common.js

Co-authored-by: Chris Breiding <chrisbreiding@users.noreply.github.com>
This commit is contained in:
Zach Panzarino
2020-07-07 10:47:03 -04:00
committed by GitHub
parent ecb3b0a43d
commit 7d468d4e2c
40 changed files with 1189 additions and 379 deletions

View File

@@ -502,20 +502,18 @@ describe('src/cy/commands/querying', () => {
})
describe('.log', () => {
beforeEach(() => {
beforeEach(function () {
this.logs = []
beforeEach(function () {
this.logs = []
cy.on('log:added', (attrs, log) => {
if (attrs.name === 'root') {
this.lastLog = log
cy.on('log:added', (attrs, log) => {
if (attrs.name === 'root') {
this.lastLog = log
this.logs.push(log)
}
})
return null
this.logs.push(log)
}
})
return null
})
it('can turn off logging', () => {

View File

@@ -11,16 +11,6 @@ Cypress.on('test:after:run', (test) => {
})
describe('src/cypress/runner', () => {
it('handles "double quotes" in test name', (done) => {
cy.once('log:added', (log) => {
expect(log.hookName).to.equal('test')
return done()
})
return cy.wrap({})
})
context('pending tests', () => {
it('is not pending', () => {})

View File

@@ -198,7 +198,7 @@ class $Cypress {
window.cy = this.cy
this.isCy = this.cy.isCy
this.log = $Log.create(this, this.cy, this.state, this.config)
this.mocha = $Mocha.create(specWindow, this)
this.mocha = $Mocha.create(specWindow, this, this.config)
this.runner = $Runner.create(specWindow, this.mocha, this, this.cy)
// wire up command create to cy

View File

@@ -1270,7 +1270,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
return snapshots.getStyles(...args)
},
setRunnable (runnable, hookName) {
setRunnable (runnable, hookId) {
// when we're setting a new runnable
// prepare to run again!
stopped = false
@@ -1278,7 +1278,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) {
// reset the promise again
state('promise', undefined)
state('hookName', hookName)
state('hookId', hookId)
state('runnable', runnable)

View File

@@ -13,7 +13,7 @@ const $errUtils = require('./error_utils')
const groupsOrTableRe = /^(groups|table)$/
const parentOrChildRe = /parent|child/
const SNAPSHOT_PROPS = 'id snapshots $el url coords highlightAttr scrollBy viewportWidth viewportHeight'.split(' ')
const DISPLAY_PROPS = 'id alias aliasType callCount displayName end err event functionName hookName instrument isStubbed message method name numElements numResponses referencesAlias renderProps state testId timeout type url visible wallClockStartedAt'.split(' ')
const DISPLAY_PROPS = 'id alias aliasType callCount displayName end err event functionName hookId instrument isStubbed message method name numElements numResponses referencesAlias renderProps state testId timeout type url visible wallClockStartedAt'.split(' ')
const BLACKLIST_PROPS = 'snapshots'.split(' ')
let delay = null
@@ -172,7 +172,7 @@ const defaults = function (state, config, obj) {
state: 'pending',
instrument: 'command',
url: state('url'),
hookName: state('hookName'),
hookId: state('hookId'),
testId: runnable ? runnable.id : undefined,
viewportWidth: state('viewportWidth'),
viewportHeight: state('viewportHeight'),

View File

@@ -1,5 +1,6 @@
const _ = require('lodash')
const $errUtils = require('./error_utils')
const $stackUtils = require('./stack_utils')
// in the browser mocha is coming back
// as window
@@ -25,6 +26,7 @@ function invokeFnWithOriginalTitle (ctx, originalTitle, mochaArgs, fn) {
return ret
}
function overloadMochaFnForConfig (fnName, specWindow) {
const _fn = specWindow[fnName]
@@ -86,7 +88,53 @@ function overloadMochaFnForConfig (fnName, specWindow) {
})
}
const ui = (specWindow, _mocha) => {
function getInvocationDetails (specWindow, config) {
if (specWindow.Error) {
let stack = (new specWindow.Error()).stack
// firefox throws a different stack than chromium
// which includes this file (mocha.js) and mocha/.../common.js at the top
if (specWindow.Cypress.browser.family === 'firefox') {
stack = $stackUtils.stackWithLinesDroppedFromMarker(stack, 'mocha/lib/interfaces/common.js')
}
return $stackUtils.getSourceDetailsForFirstLine(stack, config('projectRoot'))
}
}
function overloadMochaHook (fnName, suite, specWindow, config) {
const _fn = suite[fnName]
suite[fnName] = function (title, fn) {
const _createHook = this._createHook
this._createHook = function (title, fn) {
const hook = _createHook.call(this, title, fn)
hook.invocationDetails = getInvocationDetails(specWindow, config)
return hook
}
const ret = _fn.call(this, title, fn)
this._createHook = _createHook
return ret
}
}
function overloadMochaTest (suite, specWindow, config) {
const _fn = suite.addTest
suite.addTest = function (test) {
test.invocationDetails = getInvocationDetails(specWindow, config)
return _fn.call(this, test)
}
}
const ui = (specWindow, _mocha, config) => {
// Override mocha.ui so that the pre-require event is emitted
// with the iframe's `window` reference, rather than the parent's.
_mocha.ui = function (name) {
@@ -109,13 +157,21 @@ const ui = (specWindow, _mocha) => {
overloadMochaFnForConfig('describe', specWindow)
overloadMochaFnForConfig('context', specWindow)
// overload tests and hooks so that we can get the stack info
overloadMochaHook('beforeAll', this.suite.constructor.prototype, specWindow, config)
overloadMochaHook('beforeEach', this.suite.constructor.prototype, specWindow, config)
overloadMochaHook('afterAll', this.suite.constructor.prototype, specWindow, config)
overloadMochaHook('afterEach', this.suite.constructor.prototype, specWindow, config)
overloadMochaTest(this.suite.constructor.prototype, specWindow, config)
return this
}
return _mocha.ui('bdd')
}
const setMochaProps = (specWindow, _mocha) => {
const setMochaProps = (specWindow, _mocha, config) => {
// Mocha is usually defined in the spec when used normally
// in the browser or node, so we add it as a global
// for our users too
@@ -128,21 +184,17 @@ const setMochaProps = (specWindow, _mocha) => {
// this needs to be part of the configuration of cypress.json
// we can't just forcibly use bdd
return ui(specWindow, _mocha)
return ui(specWindow, _mocha, config)
}
const globals = (specWindow, reporter) => {
if (reporter == null) {
reporter = () => {}
}
const createMocha = (specWindow, config) => {
const _mocha = new Mocha({
reporter,
reporter: () => {},
timeout: false,
})
// set mocha props on the specWindow
setMochaProps(specWindow, _mocha)
setMochaProps(specWindow, _mocha, config)
// return the newly created mocha instance
return _mocha
@@ -269,7 +321,7 @@ const override = (Cypress) => {
patchRunnableResetTimeout()
}
const create = (specWindow, Cypress, reporter) => {
const create = (specWindow, Cypress, config) => {
restore()
override(Cypress)
@@ -277,7 +329,7 @@ const create = (specWindow, Cypress, reporter) => {
// generate the mocha + Mocha globals
// on the specWindow, and get the new
// _mocha instance
const _mocha = globals(specWindow, reporter)
const _mocha = createMocha(specWindow, config)
const _runner = getRunner(_mocha)
@@ -307,7 +359,5 @@ const create = (specWindow, Cypress, reporter) => {
module.exports = {
restore,
globals,
create,
}

View File

@@ -17,8 +17,8 @@ const HOOKS = 'beforeAll beforeEach afterEach afterAll'.split(' ')
const TEST_BEFORE_RUN_EVENT = 'runner:test:before:run'
const TEST_AFTER_RUN_EVENT = 'runner:test:after:run'
const RUNNABLE_LOGS = 'routes agents commands'.split(' ')
const RUNNABLE_PROPS = 'id order title root hookName hookId err state failedFromHookId body speed type duration wallClockStartedAt wallClockDuration timings file originalTitle'.split(' ')
const RUNNABLE_LOGS = 'routes agents commands hooks'.split(' ')
const RUNNABLE_PROPS = 'id order title root hookName hookId err state failedFromHookId body speed type duration wallClockStartedAt wallClockDuration timings file originalTitle invocationDetails'.split(' ')
const debug = require('debug')('cypress:driver:runner')
@@ -131,6 +131,27 @@ const wrapAll = (runnable) => {
)
}
const condenseHooks = (runnable, getHookId) => {
const hooks = _.compact(_.concat(
runnable._beforeAll,
runnable._beforeEach,
runnable._afterAll,
runnable._afterEach,
))
return _.map(hooks, (hook) => {
if (!hook.hookId) {
hook.hookId = getHookId()
}
if (!hook.hookName) {
hook.hookName = getHookName(hook)
}
return wrap(hook)
})
}
const getHookName = (hook) => {
// find the name of the hook by parsing its
// title and pulling out whats between the quotes
@@ -378,7 +399,7 @@ const hasOnly = (suite) => {
)
}
const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnable, onLogsById, getTestId) => {
const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnable, onLogsById, getTestId, getHookId) => {
let hasTests = false
// only loop until we find the first test
@@ -396,7 +417,7 @@ const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnab
// create optimized lookups for the tests without
// traversing through it multiple times
const tests = {}
const normalizedSuite = normalize(suite, tests, initialTests, onRunnable, onLogsById, getTestId)
const normalizedSuite = normalize(suite, tests, initialTests, onRunnable, onLogsById, getTestId, getHookId)
if (setTestsById) {
// use callback here to hand back
@@ -420,7 +441,7 @@ const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnab
return normalizedSuite
}
const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTestId) => {
const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTestId, getHookId) => {
const normalizeRunnable = (runnable) => {
let i
@@ -446,6 +467,9 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTes
_.extend(runnable, i)
}
// merge all hooks into single array
runnable.hooks = condenseHooks(runnable, getHookId)
// reduce this runnable down to its props
// and collections
return wrapAll(runnable)
@@ -466,7 +490,7 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTes
_.each({ tests: runnable.tests, suites: runnable.suites }, (_runnables, type) => {
if (runnable[type]) {
return normalizedRunnable[type] = _.map(_runnables, (runnable) => {
return normalize(runnable, tests, initialTests, onRunnable, onLogsById, getTestId)
return normalize(runnable, tests, initialTests, onRunnable, onLogsById, getTestId, getHookId)
})
}
})
@@ -480,7 +504,7 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTes
if (suite._onlyTests.length) {
suite.tests = suite._onlyTests
normalizedSuite.tests = _.map(suite._onlyTests, (test) => {
const normalizedTest = normalizeRunnable(test, initialTests, onRunnable, onLogsById, getTestId)
const normalizedTest = normalizeRunnable(test, initialTests, onRunnable, onLogsById, getTestId, getHookId)
push(normalizedTest)
@@ -493,7 +517,7 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTes
suite.tests = []
normalizedSuite.tests = []
_.each(suite._onlySuites, (onlySuite) => {
const normalizedOnlySuite = normalizeRunnable(onlySuite, initialTests, onRunnable, onLogsById, getTestId)
const normalizedOnlySuite = normalizeRunnable(onlySuite, initialTests, onRunnable, onLogsById, getTestId, getHookId)
if (hasOnly(onlySuite)) {
return filterOnly(normalizedOnlySuite, onlySuite)
@@ -501,12 +525,12 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getTes
})
suite.suites = _.filter(suite.suites, (childSuite) => {
const normalizedChildSuite = normalizeRunnable(childSuite, initialTests, onRunnable, onLogsById, getTestId)
const normalizedChildSuite = normalizeRunnable(childSuite, initialTests, onRunnable, onLogsById, getTestId, getHookId)
return (suite._onlySuites.indexOf(childSuite) !== -1) || filterOnly(normalizedChildSuite, childSuite)
})
normalizedSuite.suites = _.map(suite.suites, (childSuite) => normalize(childSuite, tests, initialTests, onRunnable, onLogsById, getTestId))
normalizedSuite.suites = _.map(suite.suites, (childSuite) => normalize(childSuite, tests, initialTests, onRunnable, onLogsById, getTestId, getHookId))
}
return suite.tests.length || suite.suites.length
@@ -585,14 +609,6 @@ const _runnerListeners = (_runner, Cypress, _emissions, getTestById, getTest, se
})
_runner.on('hook', (hook) => {
if (hook.hookId == null) {
hook.hookId = getHookId()
}
if (hook.hookName == null) {
hook.hookName = getHookName(hook)
}
// mocha incorrectly sets currentTest on before/after all's.
// if there is a nested suite with a before, then
// currentTest will refer to the previous test run
@@ -903,6 +919,7 @@ const create = (specWindow, mocha, Cypress, cy) => {
onRunnable,
onLogsById,
getTestId,
getHookId,
)
},
@@ -968,6 +985,9 @@ const create = (specWindow, mocha, Cypress, cy) => {
// if this isnt a hook, then the name is 'test'
const hookName = runnable.type === 'hook' ? getHookName(runnable) : 'test'
// set hook id to hook id or test id
const hookId = runnable.type === 'hook' ? runnable.hookId : runnable.id
// if we haven't yet fired this event for this test
// that means that we need to reset the previous state
// of cy - since we now have a new 'test' and all of the
@@ -1061,7 +1081,7 @@ const create = (specWindow, mocha, Cypress, cy) => {
// running lifecycle events
// and also get back a function result handler that we use as
// an async seam
cy.setRunnable(runnable, hookName)
cy.setRunnable(runnable, hookId)
// TODO: handle promise timeouts here!
// whenever any runnable is about to run

View File

@@ -241,6 +241,14 @@ const getSourceDetailsForLine = (projectRoot, line) => {
}
}
const getSourceDetailsForFirstLine = (stack, projectRoot) => {
const line = getStackLines(stack)[0]
if (!line) return
return getSourceDetailsForLine(projectRoot, line)
}
const reconstructStack = (parsedStack) => {
return _.map(parsedStack, (parsedLine) => {
if (parsedLine.message != null) {
@@ -330,6 +338,7 @@ module.exports = {
getCodeFrame,
getSourceStack,
getStackLines,
getSourceDetailsForFirstLine,
hasCrossFrameStacks,
normalizedStack,
normalizedUserInvocationStack,

View File

@@ -2,14 +2,14 @@
"id": "r1",
"title": "",
"root": true,
"tests": [
],
"hooks": [],
"tests": [],
"suites": [
{
"id": "r2",
"title": "suite 1",
"root": false,
"hooks": [],
"tests": [
{
"id": "r3",
@@ -27,6 +27,7 @@
"id": "r5",
"title": "nested suite 1",
"root": false,
"hooks": [],
"tests": [
{
"id": "r6",
@@ -39,7 +40,7 @@
"state": "active",
"commands": [
{
"hookName": "test",
"hookId": "r7",
"id": "c1",
"instrument": "command",
"message": "http://localhost:3000",

View File

@@ -2,6 +2,7 @@
"id": "r1",
"title": "",
"root": true,
"hooks": [],
"suites": [],
"tests": [
{

View File

@@ -3,11 +3,13 @@
"title": "",
"root": true,
"tests": [],
"hooks": [],
"suites": [
{
"id": "r2",
"title": "suite 1",
"root": false,
"hooks": [],
"tests": [
{
"id": "r3",
@@ -20,7 +22,7 @@
},
"commands": [
{
"hookName": "test",
"hookId": "r3",
"id": "c1",
"instrument": "command",
"message": "http://localhost:3000",

View File

@@ -0,0 +1,199 @@
{
"id": "r1",
"title": "",
"root": true,
"hooks": [
{
"hookId": "h1",
"hookName": "before each",
"invocationDetails": {
"absoluteFile": "/absolute/path/to/foo_spec.js",
"column": 4,
"line": 10,
"originalFile": "path/to/foo_spec.js",
"relativeFile": "path/to/foo_spec.js"
}
}
],
"tests": [],
"suites": [
{
"id": "r2",
"title": "suite 1",
"root": false,
"hooks": [
{
"hookId": "h2",
"hookName": "after each"
}
],
"tests": [
{
"id": "r3",
"title": "test 1",
"state": "passed",
"commands": [
{
"hookId": "h1",
"id": "c1",
"instrument": "command",
"message": "http://localhost:3000",
"name": "visit",
"state": "passed",
"testId": "r3",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h1",
"id": "c2",
"instrument": "command",
"message": ".wrapper",
"name": "get",
"state": "passed",
"testId": "r3",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "r3",
"id": "c3",
"instrument": "command",
"message": ".body",
"name": "get",
"state": "passed",
"testId": "r3",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h2",
"id": "c4",
"instrument": "command",
"message": ".cleanup",
"name": "get",
"state": "passed",
"testId": "r3",
"timeout": 4000,
"type": "parent"
}
],
"invocationDetails": {
"absoluteFile": "/absolute/path/to/foo_spec.js",
"column": 8,
"line": 34,
"originalFile": "path/to/foo_spec.js",
"relativeFile": "path/to/foo_spec.js"
}
}
],
"suites": [
{
"id": "r4",
"title": "nested suite 1",
"root": false,
"hooks": [
{
"hookId": "h3",
"hookName": "before all"
},
{
"hookId": "h4",
"hookName": "before all"
},
{
"hookId": "h5",
"hookName": "before each"
}
],
"tests": [
{
"id": "r5",
"title": "test 2",
"state": "passed",
"commands": [
{
"hookId": "h3",
"id": "c5",
"instrument": "command",
"message": "before1",
"name": "log",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h4",
"id": "c5",
"instrument": "command",
"message": "before2",
"name": "log",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h1",
"id": "c5",
"instrument": "command",
"message": "http://localhost:3000",
"name": "visit",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h1",
"id": "c6",
"instrument": "command",
"message": ".wrapper",
"name": "get",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h5",
"id": "c7",
"instrument": "command",
"message": ".header",
"name": "get",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "r5",
"id": "c8",
"instrument": "command",
"message": ".body",
"name": "get",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
},
{
"hookId": "h2",
"id": "c9",
"instrument": "command",
"message": ".cleanup",
"name": "get",
"state": "passed",
"testId": "r5",
"timeout": 4000,
"type": "parent"
}
]
}
]
}
]
}
]
}

View File

@@ -4,7 +4,7 @@ const { _ } = Cypress
const addLog = function (runner, log) {
const defaultLog = {
event: false,
hookName: 'test',
hookId: 'r3',
id: _.uniqueId('l'),
instrument: 'command',
renderProps: {},

View File

@@ -0,0 +1,113 @@
import { EventEmitter } from 'events'
import { itHandlesFileOpening } from '../support/utils'
describe('hooks', function () {
beforeEach(function () {
cy.fixture('runnables_hooks').as('runnables')
this.runner = new EventEmitter()
cy.visit('/dist').then((win) => {
win.render({
runner: this.runner,
spec: {
name: 'foo.js',
relative: 'relative/path/to/foo.js',
absolute: '/absolute/path/to/foo.js',
},
})
})
cy.get('.reporter').then(() => {
this.runner.emit('runnables:ready', this.runnables)
this.runner.emit('reporter:start', {})
})
})
describe('group hooks', function () {
beforeEach(function () {
cy.contains('test 1').click()
})
it('assigns commands to the correct hook', function () {
cy.contains('before each').closest('.collapsible').find('.command').should('have.length', 2)
cy.contains('before each').closest('.collapsible').should('contain', 'http://localhost:3000')
cy.contains('before each').closest('.collapsible').should('contain', '.wrapper')
cy.contains('test body').closest('.collapsible').find('.command').should('have.length', 1)
cy.contains('test body').closest('.collapsible').should('contain', '.body')
cy.contains('after each').closest('.collapsible').find('.command').should('have.length', 1)
cy.contains('after each').closest('.collapsible').should('contain', '.cleanup')
})
it('displays hooks in the correct order', function () {
const hooks = ['before each', 'test body', 'after each']
cy.get('.hooks-container').find('span.hook-name').each(function (name, i) {
cy.wrap(name).should('contain', hooks[i])
})
})
})
describe('split hooks', function () {
beforeEach(function () {
cy.contains('test 2').click()
})
it('splits different hooks with the same name', function () {
cy.contains('before all (1)').closest('.collapsible').find('.command').should('have.length', 1)
cy.contains('before all (1)').closest('.collapsible').should('contain', 'before1')
cy.contains('before all (2)').closest('.collapsible').find('.command').should('have.length', 1)
cy.contains('before all (2)').closest('.collapsible').should('contain', 'before2')
cy.contains('before each (1)').closest('.collapsible').find('.command').should('have.length', 2)
cy.contains('before each (1)').closest('.collapsible').should('contain', 'http://localhost:3000')
cy.contains('before each (1)').closest('.collapsible').should('contain', '.wrapper')
cy.contains('before each (2)').closest('.collapsible').find('.command').should('have.length', 1)
cy.contains('before each (2)').closest('.collapsible').should('contain', '.header')
})
it('does not display hook number when only one', function () {
cy.get('.hooks-container').should('contain', 'after each')
cy.get('.hooks-container').should('not.contain', 'after each (1)')
})
})
describe('open hooks in IDE', function () {
beforeEach(function () {
cy.contains('test 1').click()
})
it('does not display button without hover', function () {
cy.contains('Open in IDE').should('not.be.visible')
})
it('creates button when hook has invocation details', function () {
cy.contains('before each').closest('.hook-header').should('contain', 'Open in IDE')
})
it('creates button when test has invocation details', function () {
cy.contains('test body').closest('.hook-header').should('contain', 'Open in IDE')
})
it('does not create button when hook does not have invocation details', function () {
cy.contains('after each').closest('.hook-header').should('not.contain', 'Open in IDE')
})
describe('handles file opening', function () {
beforeEach(function () {
cy.get('.hook-open-in-ide').first().invoke('show')
})
itHandlesFileOpening('.hook-open-in-ide', {
file: '/absolute/path/to/foo_spec.js',
column: 4,
line: 10,
})
})
})
})

View File

@@ -239,7 +239,14 @@ describe('test errors', function () {
cy.get('.command-wrapper').should('be.visible')
})
itHandlesFileOpening('.runnable-err-stack-trace', {
it('displays tooltip on hover', () => {
cy.contains('View stack trace').click()
cy.get('.runnable-err-stack-trace a').first().trigger('mouseover')
cy.get('.cy-tooltip').first().should('have.text', 'Open in IDE')
})
itHandlesFileOpening('.runnable-err-stack-trace a', {
file: '/me/dev/my/app.js',
line: 2,
column: 7,
@@ -326,7 +333,12 @@ describe('test errors', function () {
.should('have.class', 'language-text')
})
itHandlesFileOpening('.test-err-code-frame', {
it('displays tooltip on hover', () => {
cy.get('.test-err-code-frame a').first().trigger('mouseover')
cy.get('.cy-tooltip').first().should('have.text', 'Open in IDE')
})
itHandlesFileOpening('.test-err-code-frame a', {
file: '/me/dev/my/app.js',
line: 2,
column: 7,

View File

@@ -118,7 +118,12 @@ describe('controls', function () {
cy.get('.runnable-header').find('a').should('have.text', 'relative/path/to/foo.js')
})
itHandlesFileOpening('.runnable-header', {
it('displays tooltip on hover', () => {
cy.get('.runnable-header a').first().trigger('mouseover')
cy.get('.cy-tooltip').first().should('have.text', 'Open in IDE')
})
itHandlesFileOpening('.runnable-header a', {
file: '/absolute/path/to/foo.js',
line: 0,
column: 0,

View File

@@ -1,19 +1,10 @@
const _ = Cypress._
export const itHandlesFileOpening = (containerSelector, file, stackTrace = false) => {
export const itHandlesFileOpening = (selector, file, stackTrace = false) => {
beforeEach(function () {
cy.stub(this.runner, 'emit').callThrough()
})
it('displays tooltip on hover', () => {
if (stackTrace) {
cy.contains('View stack trace').click()
}
cy.get(`${containerSelector} a`).first().trigger('mouseover')
cy.get('.cy-tooltip').first().should('have.text', 'Open in IDE')
})
describe('when user has already set opener and opens file', function () {
beforeEach(function () {
this.editor = {}
@@ -28,7 +19,7 @@ export const itHandlesFileOpening = (containerSelector, file, stackTrace = false
})
it('opens in preferred opener', function () {
cy.get(`${containerSelector} a`).first().click().then(() => {
cy.get(selector).first().click().then(() => {
expect(this.runner.emit).to.be.calledWith('open:file', {
where: this.editor,
...file,
@@ -56,7 +47,7 @@ export const itHandlesFileOpening = (containerSelector, file, stackTrace = false
cy.contains('View stack trace').click()
}
cy.get(`${containerSelector} a`).first().click()
cy.get(selector).first().click()
})
it('opens modal with available editors', function () {

View File

@@ -20,7 +20,7 @@ export interface CommandProps extends InstrumentProps {
timeout: number
visible?: boolean
wallClockStartedAt: string
hookName: string
hookId: string
}
export default class Command extends Instrument {
@@ -35,6 +35,7 @@ export default class Command extends Instrument {
@observable wallClockStartedAt: string
@observable duplicates: Array<Command> = []
@observable isDuplicate = false
@observable hookId: string
private _prevState: string | null | undefined = null
private _pendingTimeout?: TimeoutID = undefined
@@ -63,6 +64,7 @@ export default class Command extends Instrument {
this.timeout = props.timeout
this.visible = props.visible
this.wallClockStartedAt = props.wallClockStartedAt
this.hookId = props.hookId
this._checkLongRunning()
}

View File

@@ -16,35 +16,54 @@
margin-right: 3px;
}
.hook-name > .collapsible-header {
border-bottom: 1px solid transparent;
text-transform: uppercase;
color: #959595;
display: inline-block;
font-size: 11px;
cursor: pointer;
&:focus {
outline: 1px dotted #6c6c6c;
}
.hook-header {
display: flex;
width: 100%;
&:hover {
border-bottom: 1px dotted #959595;
color: #333;
.collapsible-header {
color: #6a6b6c;
}
.hook-open-in-ide {
display: block;
}
}
&:hover:focus {
border-bottom: 1px dotted #959595;
color: #333;
.collapsible-header {
text-transform: uppercase;
color: #959595;
display: inline-block;
flex-grow: 1;
font-size: 11px;
cursor: pointer;
padding: 4px 0;
&:focus {
outline: 1px dotted #6c6c6c;
}
> .collapsible-header-inner:focus {
outline: 0;
}
.hook-failed-message {
color: #E94F5F;
display: none;
}
}
> .collapsible-header-inner:focus {
outline: 0;
}
.hook-failed-message {
color: #E94F5F;
.hook-open-in-ide {
align-items: center;
color: #6a6b6c;
display: none;
padding: 4px 10px;
&:hover, &:focus {
background-color: rgba(186, 186, 186, 0.2);
outline: none;
text-decoration: none;
}
}
}

View File

@@ -3,7 +3,7 @@ import { observer } from 'mobx-react'
import Prism from 'prismjs'
import { CodeFrame } from './err-model'
import FileOpener from '../lib/file-opener'
import FileNameOpener from '../lib/file-name-opener'
interface Props {
codeFrame: CodeFrame
@@ -24,7 +24,7 @@ class ErrorCodeFrame extends Component<Props> {
return (
<div className='test-err-code-frame'>
<FileOpener className="runnable-err-file-path" fileDetails={this.props.codeFrame} />
<FileNameOpener className="runnable-err-file-path" fileDetails={this.props.codeFrame} />
<pre ref='codeFrame' data-line={highlightLine}>
<code className={`language-${language || 'text'}`}>{frame}</code>
</pre>

View File

@@ -2,7 +2,7 @@ import _ from 'lodash'
import { observer } from 'mobx-react'
import React, { ReactElement } from 'react'
import FileOpener from '../lib/file-opener'
import FileNameOpener from '../lib/file-name-opener'
import Err from './err-model'
const cypressLineRegex = /(cypress:\/\/|cypress_runner\.js)/
@@ -62,7 +62,7 @@ const ErrorStack = observer(({ err }: Props) => {
}
const link = (
<FileOpener key={key} className="runnable-err-file-path" fileDetails={stackLine} />
<FileNameOpener key={key} className="runnable-err-file-path" fileDetails={stackLine} />
)
return makeLine(key, [whitespace, `at ${fn} (`, link, ')'])

View File

@@ -9,13 +9,10 @@ describe('Hook model', () => {
let hook: Hook
beforeEach(() => {
hook = new Hook({ name: 'before' })
})
it('gives hooks unique ids', () => {
const anotherHook = new Hook({ name: 'test' })
expect(hook.id).not.to.equal(anotherHook.id)
hook = new Hook({
hookId: 'h1',
hookName: 'before each',
})
})
context('#addCommand', () => {
@@ -151,7 +148,7 @@ describe('Hook model', () => {
return hook.addCommand(command as CommandModel)
}
it('returns duplicates marked with hasDuplicates and those that appear mulitple times in the commands array', () => {
it('returns duplicates marked with hasDuplicates and those that appear multiple times in the commands array', () => {
addCommand('foo')
addCommand('bar')
addCommand('foo')

View File

@@ -1,22 +1,36 @@
import _ from 'lodash'
import { observable, computed } from 'mobx'
import { FileDetails } from '@packages/ui-components'
import { Alias } from '../instruments/instrument-model'
import Err from '../errors/err-model'
import CommandModel from '../commands/command-model'
export default class Hook {
@observable id: string
@observable name: string
export type HookName = 'before all' | 'before each' | 'after all' | 'after each' | 'test body'
export interface HookProps {
hookId: string
hookName: HookName
invocationDetails?: FileDetails
}
export default class Hook implements HookProps {
@observable hookId: string
@observable hookName: HookName
@observable hookNumber?: number
@observable invocationDetails?: FileDetails
@observable invocationOrder?: number
@observable commands: Array<CommandModel> = []
@observable failed = false
private _aliasesWithDuplicatesCache: Array<Alias> | null = null
private _currentNumber = 1
constructor (props: { name: string }) {
this.id = _.uniqueId('h')
this.name = props.name
constructor (props: HookProps) {
this.hookId = props.hookId
this.hookName = props.hookName
this.invocationDetails = props.invocationDetails
}
@computed get aliasesWithDuplicates () {

View File

@@ -11,8 +11,8 @@ const commandModel = () => {
const hookModel = (props?: Partial<HookModel>) => {
return _.extend<HookModel>({
id: _.uniqueId('h'),
name: 'before',
hookId: _.uniqueId('h'),
hookName: 'before each',
failed: false,
commands: [commandModel(), commandModel()],
}, props)
@@ -21,6 +21,13 @@ const hookModel = (props?: Partial<HookModel>) => {
const model = (props?: Partial<HooksModel>) => {
return _.extend<HooksModel>({
hooks: [hookModel(), hookModel(), hookModel()],
hookCount: {
'before all': 0,
'before each': 3,
'after all': 0,
'after each': 0,
'test body': 0,
},
}, props)
}
@@ -31,34 +38,55 @@ describe('<Hooks />', () => {
expect(component.find(Hook).length).to.equal(3)
})
it('renders a number when there are multiple hooks of the same name', () => {
const component = shallow(<Hooks model={model()} />)
expect(component.find(Hook).first()).to.have.prop('showNumber', true)
})
it('does not render a number when there are not multiple hooks of the same name', () => {
const component = shallow(<Hooks model={model({
hooks: [hookModel()],
hookCount: {
'before all': 0,
'before each': 1,
'after all': 0,
'after each': 0,
'test body': 0,
},
})} />)
expect(component.find(Hook).first()).to.have.prop('showNumber', false)
})
context('<Hook />', () => {
it('renders without hook-failed class when not failed', () => {
const component = shallow(<Hook model={hookModel()} />)
const component = shallow(<Hook model={hookModel()} showNumber={false} />)
expect(component).not.to.have.className('hook-failed')
})
it('renders with hook-failed class when failed', () => {
const component = shallow(<Hook model={hookModel({ failed: true })} />)
const component = shallow(<Hook model={hookModel({ failed: true })} showNumber={false} />)
expect(component).to.have.className('hook-failed')
})
it('renders Collapsible with hook header', () => {
const component = shallow(<Hook model={hookModel()} />)
const component = shallow(<Hook model={hookModel()} showNumber={false} />)
const header = shallow(component.find('Collapsible').prop('header'))
expect(header.find('.hook-failed-message')).to.have.text('(failed)')
})
it('renders Collapsible open', () => {
const component = shallow(<Hook model={hookModel()} />)
const component = shallow(<Hook model={hookModel()} showNumber={false} />)
expect(component.find('Collapsible').prop('isOpen')).to.be.true
})
it('renders command for each in model', () => {
const component = shallow(<Hook model={hookModel()} />)
const component = shallow(<Hook model={hookModel()} showNumber={false} />)
expect(component.find('Command').length).to.equal(2)
})
@@ -76,5 +104,11 @@ describe('<Hooks />', () => {
expect(component.find('.hook-failed-message')).to.have.text('(failed)')
})
it('renders the number', () => {
const component = shallow(<HookHeader name='before' number={1} />)
expect(component.text()).to.contain('before (1)')
})
})
})

View File

@@ -2,29 +2,45 @@ import cs from 'classnames'
import _ from 'lodash'
import { observer } from 'mobx-react'
import React from 'react'
import { FileDetails } from '@packages/ui-components'
import Command from '../commands/command'
import Collapsible from '../collapsible/collapsible'
import HookModel from './hook-model'
import HookModel, { HookName } from './hook-model'
import FileOpener from '../lib/file-opener'
export interface HookHeaderProps {
name: string
number?: number
}
const HookHeader = ({ name }: HookHeaderProps) => (
<span>
{name === 'test' ? 'test body' : name} <span className='hook-failed-message'>(failed)</span>
const HookHeader = ({ name, number }: HookHeaderProps) => (
<span className='hook-name'>
{name} {number && `(${number})`} <span className='hook-failed-message'>(failed)</span>
</span>
)
export interface HookOpenInIDEProps {
invocationDetails: FileDetails
}
const HookOpenInIDE = ({ invocationDetails }: HookOpenInIDEProps) => (
<FileOpener fileDetails={invocationDetails} className='hook-open-in-ide'>
<i className="fas fa-external-link-alt fa-sm" /> <span>Open in IDE</span>
</FileOpener>
)
export interface HookProps {
model: HookModel
showNumber: boolean
}
const Hook = observer(({ model }: HookProps) => (
const Hook = observer(({ model, showNumber }: HookProps) => (
<li className={cs('hook-item', { 'hook-failed': model.failed })}>
<Collapsible
header={<HookHeader name={model.name} />}
headerClass='hook-name'
header={<HookHeader name={model.hookName} number={showNumber ? model.hookNumber : undefined} />}
headerClass='hook-header'
headerExtras={model.invocationDetails && <HookOpenInIDE invocationDetails={model.invocationDetails} />}
isOpen={true}
>
<ul className='commands-container'>
@@ -36,6 +52,7 @@ const Hook = observer(({ model }: HookProps) => (
export interface HooksModel {
hooks: Array<HookModel>
hookCount: { [name in HookName]: number }
}
export interface HooksProps {
@@ -44,7 +61,7 @@ export interface HooksProps {
const Hooks = observer(({ model }: HooksProps) => (
<ul className='hooks-container'>
{_.map(model.hooks, (hook) => <Hook key={hook.id} model={hook} />)}
{_.map(model.hooks, (hook) => hook.commands.length ? <Hook key={hook.hookId} model={hook} showNumber={model.hookCount[hook.hookName] > 1} /> : undefined)}
</ul>
))

View File

@@ -0,0 +1,28 @@
import { observer } from 'mobx-react'
import React from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
import { FileDetails } from '@packages/ui-components'
import FileOpener from './file-opener'
interface Props {
fileDetails: FileDetails,
className?: string
}
const FileNameOpener = observer((props: Props) => {
const { originalFile, line, column } = props.fileDetails
return (
<Tooltip title={'Open in IDE'} wrapperClassName={props.className} className='cy-tooltip'>
<span>
<FileOpener fileDetails={props.fileDetails}>
{originalFile}{!!line && `:${line}`}{!!column && `:${column}`}
</FileOpener>
</span>
</Tooltip>
)
})
export default FileNameOpener

View File

@@ -1,14 +1,12 @@
import { observer } from 'mobx-react'
import React from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
import React, { ReactNode } from 'react'
import { GetUserEditorResult, Editor, FileDetails, FileOpener as Opener } from '@packages/ui-components'
import events from './events'
import { GetUserEditorResult, Editor, FileDetails, FileOpener as Opener } from '@packages/ui-components'
interface Props {
fileDetails: FileDetails,
fileDetails: FileDetails
children: ReactNode
className?: string
}
@@ -29,23 +27,16 @@ const setUserEditor = (editor: Editor) => {
events.emit('set:user:editor', editor)
}
const FileOpener = observer((props: Props) => {
const { originalFile, line, column } = props.fileDetails
return (
<Tooltip title={'Open in IDE'} wrapperClassName={props.className} className='cy-tooltip'>
<span>
<Opener
openFile={openFile}
getUserEditor={getUserEditor}
setUserEditor={setUserEditor}
fileDetails={props.fileDetails}
>
{originalFile}{!!line && `:${line}`}{!!column && `:${column}`}
</Opener>
</span>
</Tooltip>
)
})
const FileOpener = observer(({ fileDetails, children, className }: Props) => (
<Opener
openFile={openFile}
getUserEditor={getUserEditor}
setUserEditor={setUserEditor}
fileDetails={fileDetails}
className={className}
>
{children}
</Opener>
))
export default FileOpener

View File

@@ -1,6 +1,6 @@
import React, { Component, ReactElement } from 'react'
import FileOpener from '../lib/file-opener'
import FileNameOpener from '../lib/file-name-opener'
const renderRunnableHeader = (children:ReactElement) => <div className="runnable-header">{children}</div>
@@ -28,7 +28,7 @@ class RunnableHeader extends Component<RunnableHeaderProps> {
}
return renderRunnableHeader(
<FileOpener fileDetails={fileDetails} />,
<FileNameOpener fileDetails={fileDetails} />,
)
}
}

View File

@@ -1,8 +1,10 @@
import { observable } from 'mobx'
import { HookProps } from '../hooks/hook-model'
export interface RunnableProps {
id: number
title?: string
hooks: Array<HookProps>
}
export default class Runnable {
@@ -10,10 +12,12 @@ export default class Runnable {
@observable shouldRender: boolean = false
@observable title?: string
@observable level: number
@observable hooks: Array<HookProps> = []
constructor (props: RunnableProps, level: number) {
this.id = props.id
this.title = props.title
this.level = level
this.hooks = props.hooks
}
}

View File

@@ -9,6 +9,7 @@ import TestModel, { TestProps } from '../test/test-model'
import { AgentProps } from '../agents/agent-model'
import { CommandProps } from '../commands/command-model'
import { RouteProps } from '../routes/route-model'
import { HookProps } from '../hooks/hook-model'
const appStateStub = () => {
return {
@@ -29,17 +30,20 @@ const scrollerStub = () => {
} as ScrollerStub
}
const createHook = (hookId: string) => {
return { hookId, hookName: 'before each' } as HookProps
}
const createTest = (id: number) => {
return { id, title: `test ${id}` } as TestProps
return { id, title: `test ${id}`, hooks: [], state: 'processing' } as TestProps
}
const createSuite = (id: number, tests: Array<TestProps>, suites: Array<SuiteProps>) => {
return { id, title: `suite ${id}`, tests, suites } as SuiteProps
return { id, title: `suite ${id}`, tests, suites, hooks: [] } as SuiteProps
}
const createAgent = (id: number, testId: number) => {
return { id, testId, instrument: 'agent' } as AgentProps
}
const createCommand = (id: number, testId: number) => {
return { id, testId, instrument: 'command' } as CommandProps
const createCommand = (id: number, testId: number, hookId?: string) => {
return { id, testId, instrument: 'command', hookId } as CommandProps
}
const createRoute = (id: number, testId: number) => {
return { id, testId, instrument: 'route' } as RouteProps
@@ -95,8 +99,9 @@ describe('runnables store', () => {
const rootRunnable = createRootRunnable()
rootRunnable.tests![0].agents = [createAgent(1, 1), createAgent(2, 1), createAgent(3, 1)]
rootRunnable.tests![0].commands = [createCommand(1, 1)]
rootRunnable.tests![0].commands = [createCommand(1, 1, 'h1')]
rootRunnable.tests![0].routes = [createRoute(1, 1), createRoute(2, 1)]
rootRunnable.tests![0].hooks = [createHook('h1')]
instance.setRunnables(rootRunnable)
expect((instance.runnables[0] as TestModel).agents.length).to.equal(3)
expect((instance.runnables[0] as TestModel).commands.length).to.equal(1)
@@ -111,6 +116,19 @@ describe('runnables store', () => {
expect(((instance.runnables[1] as SuiteModel).children[2] as SuiteModel).children[0].level).to.equal(2)
})
it('merges down hooks', () => {
const rootRunnable = createRootRunnable()
rootRunnable.suites![0].hooks = [createHook('h1'), createHook('h2')]
rootRunnable.suites![0].suites[0].hooks = [createHook('h3')]
rootRunnable.suites![0].suites[0].tests[0].hooks = [createHook('h4')]
instance.setRunnables(rootRunnable)
expect(instance.runnables[0].hooks.length).to.equal(1)
expect(instance.runnables[1].hooks.length).to.equal(2)
expect((instance.runnables[1] as SuiteModel).children[2].hooks.length).to.equal(3)
expect(((instance.runnables[1] as SuiteModel).children[2] as SuiteModel).children[0].hooks.length).to.equal(5)
})
it('sets .isReady flag', () => {
instance.setRunnables({})
expect(instance.isReady).to.be.true
@@ -206,8 +224,12 @@ describe('runnables store', () => {
context('#updateLog', () => {
it('updates the log', () => {
instance.setRunnables({ tests: [createTest(1)] })
instance.addLog(createCommand(1, 1))
const test = createTest(1)
test.hooks = [createHook('h1')]
instance.setRunnables({ tests: [test] })
instance.addLog(createCommand(1, 1, 'h1'))
instance.updateLog({ id: 1, name: 'new name' } as LogProps)
expect(instance.testById(1).commands[0].name).to.equal('new name')
})

View File

@@ -6,6 +6,7 @@ import AgentModel, { AgentProps } from '../agents/agent-model'
import CommandModel, { CommandProps } from '../commands/command-model'
import RouteModel, { RouteProps } from '../routes/route-model'
import scroller, { Scroller } from '../lib/scroller'
import { HookProps } from '../hooks/hook-model'
import SuiteModel, { SuiteProps } from './suite-model'
import TestModel, { TestProps, UpdateTestCallback } from '../test/test-model'
import RunnableModel from './runnable-model'
@@ -31,6 +32,7 @@ export type RunnableArray = Array<TestModel | SuiteModel>
type Log = AgentModel | CommandModel | RouteModel
export interface RootRunnable {
hooks?: Array<HookProps>
tests?: Array<TestProps>
suites?: Array<SuiteProps>
}
@@ -73,18 +75,20 @@ class RunnablesStore {
}
_createRunnableChildren (runnableProps: RootRunnable, level: number) {
return this._createRunnables<TestProps>('test', runnableProps.tests || [], level).concat(
this._createRunnables<SuiteProps>('suite', runnableProps.suites || [], level),
return this._createRunnables<TestProps>('test', runnableProps.tests || [], runnableProps.hooks || [], level).concat(
this._createRunnables<SuiteProps>('suite', runnableProps.suites || [], runnableProps.hooks || [], level),
)
}
_createRunnables<T> (type: RunnableType, runnables: Array<TestOrSuite<T>>, level: number) {
_createRunnables<T> (type: RunnableType, runnables: Array<TestOrSuite<T>>, hooks: Array<HookProps>, level: number) {
return _.map(runnables, (runnableProps) => {
return this._createRunnable(type, runnableProps, level)
return this._createRunnable(type, runnableProps, hooks, level)
})
}
_createRunnable<T> (type: RunnableType, props: TestOrSuite<T>, level: number) {
_createRunnable<T> (type: RunnableType, props: TestOrSuite<T>, hooks: Array<HookProps>, level: number) {
props.hooks = _.unionBy(props.hooks, hooks, 'hookId')
return type === 'suite' ? this._createSuite(props as SuiteProps, level) : this._createTest(props as TestProps, level)
}
@@ -176,7 +180,7 @@ class RunnablesStore {
this._logs[log.id] = command
this._withTest(log.testId, (test) => {
return test.addCommand(command, (log as CommandProps).hookName)
return test.addCommand(command)
})
break

View File

@@ -2,7 +2,7 @@ import Suite from './suite-model'
import TestModel from '../test/test-model'
const suiteWithChildren = (children: Array<Partial<TestModel>>) => {
const suite = new Suite({ id: 1, title: '' }, 0)
const suite = new Suite({ id: 1, title: '', hooks: [] }, 0)
suite.children = children as Array<TestModel>

View File

@@ -1,3 +1,4 @@
import { HookProps } from '../hooks/hook-model'
import Command, { CommandProps } from '../commands/command-model'
import Agent from '../agents/agent-model'
import Route from '../routes/route-model'
@@ -5,23 +6,32 @@ import Err from '../errors/err-model'
import TestModel, { TestProps } from './test-model'
const commandHook: (hookId: string) => Partial<Command> = (hookId: string) => {
return {
hookId,
isMatchingEvent: () => {
return false
},
}
}
describe('Test model', () => {
context('.state', () => {
it('is the "state" when it exists', () => {
const test = new TestModel({ state: 'passed' } as TestProps, 0)
const test = new TestModel({ id: 1, state: 'passed' } as TestProps, 0)
expect(test.state).to.equal('passed')
})
it('is active when there is no state and isActive is true', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.isActive = true
expect(test.state).to.equal('active')
})
it('is processing when there is no state and isActive is falsey', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
expect(test.state).to.equal('processing')
})
@@ -29,31 +39,31 @@ describe('Test model', () => {
context('.isLongRunning', () => {
it('start out not long running', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
expect(test.isLongRunning).to.be.false
})
it('is not long running if active but without a long running command', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.start()
expect(test.isLongRunning).to.be.false
})
it('becomes long running if active and has a long running command', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.start()
test.addCommand({ isLongRunning: true } as Command, '')
test.addCommand({ isLongRunning: true, hookId: 'h1' } as Command)
expect(test.isLongRunning).to.be.true
})
it('becomes not long running if it becomes inactive', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.start()
test.addCommand({ isLongRunning: true } as Command, '')
test.addCommand({ isLongRunning: true, hookId: 'h1' } as Command)
test.finish({})
expect(test.isLongRunning).to.be.false
})
@@ -61,7 +71,7 @@ describe('Test model', () => {
context('#addAgent', () => {
it('adds the agent to the agents collection', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.addAgent({} as Agent)
expect(test.agents.length).to.equal(1)
@@ -70,7 +80,7 @@ describe('Test model', () => {
context('#addRoute', () => {
it('adds the route to the routes collection', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.addRoute({} as Route)
expect(test.routes.length).to.equal(1)
@@ -79,39 +89,87 @@ describe('Test model', () => {
context('#addCommand', () => {
it('adds the command to the commands collection', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.addCommand({} as Command, '')
test.addCommand({ hookId: 'h1' } as Command)
expect(test.commands.length).to.equal(1)
})
it('creates a hook and adds the command to it if it does not exist', () => {
const test = new TestModel({} as TestProps, 0)
it('adds the command to the correct hook', () => {
const test = new TestModel({
id: 1,
hooks: [
{ hookId: 'h1' } as HookProps,
{ hookId: 'h2' } as HookProps,
],
} as TestProps, 0)
test.addCommand({} as Command, 'some hook')
expect(test.hooks.length).to.equal(1)
test.addCommand(commandHook('h1') as Command)
expect(test.hooks[0].commands.length).to.equal(1)
expect(test.hooks[1].commands.length).to.equal(0)
expect(test.hooks[2].commands.length).to.equal(0)
test.addCommand(commandHook('1') as Command)
expect(test.hooks[0].commands.length).to.equal(1)
expect(test.hooks[1].commands.length).to.equal(1)
expect(test.hooks[2].commands.length).to.equal(0)
})
it('adds the command to an existing hook if it already exists', () => {
const test = new TestModel({} as TestProps, 0)
const command: Partial<Command> = { isMatchingEvent: () => {
return false
} }
it('moves hooks into the correct order', () => {
const test = new TestModel({
id: 1,
hooks: [
{ hookId: 'h1' } as HookProps,
{ hookId: 'h2' } as HookProps,
],
} as TestProps, 0)
test.addCommand(command as Command, 'some hook')
expect(test.hooks.length).to.equal(1)
test.addCommand(commandHook('h2') as Command)
expect(test.hooks[0].hookId).to.equal('h2')
expect(test.hooks[0].invocationOrder).to.equal(0)
expect(test.hooks[0].commands.length).to.equal(1)
test.addCommand({} as Command, 'some hook')
expect(test.hooks.length).to.equal(1)
expect(test.hooks[0].commands.length).to.equal(2)
test.addCommand(commandHook('h1') as Command)
expect(test.hooks[1].hookId).to.equal('h1')
expect(test.hooks[1].invocationOrder).to.equal(1)
expect(test.hooks[1].commands.length).to.equal(1)
})
it('counts and assigns the number of each hook type', () => {
const test = new TestModel({
id: 1,
hooks: [
{ hookId: 'h1', hookName: 'before each' } as HookProps,
{ hookId: 'h2', hookName: 'after each' } as HookProps,
{ hookId: 'h3', hookName: 'before each' } as HookProps,
],
} as TestProps, 0)
test.addCommand(commandHook('h1') as Command)
expect(test.hookCount['before each']).to.equal(1)
expect(test.hookCount['after each']).to.equal(0)
expect(test.hooks[0].hookNumber).to.equal(1)
test.addCommand(commandHook('h1') as Command)
expect(test.hookCount['before each']).to.equal(1)
expect(test.hookCount['after each']).to.equal(0)
expect(test.hooks[0].hookNumber).to.equal(1)
test.addCommand(commandHook('h3') as Command)
expect(test.hookCount['before each']).to.equal(2)
expect(test.hookCount['after each']).to.equal(0)
expect(test.hooks[1].hookNumber).to.equal(2)
test.addCommand(commandHook('h2') as Command)
expect(test.hookCount['before each']).to.equal(2)
expect(test.hookCount['after each']).to.equal(1)
expect(test.hooks[2].hookNumber).to.equal(1)
})
})
context('#start', () => {
it('sets the test as active', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.start()
expect(test.isActive).to.be.true
@@ -120,61 +178,61 @@ describe('Test model', () => {
context('#finish', () => {
it('sets the test as inactive', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.finish({})
expect(test.isActive).to.be.false
})
it('updates the state of the test', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.finish({ state: 'failed' })
expect(test.state).to.equal('failed')
})
it('updates the test err', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
test.finish({ err: { name: 'SomeError' } as Err })
expect(test.err.name).to.equal('SomeError')
})
it('sets the hook to failed if it exists', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.addCommand({} as Command, 'some hook')
test.finish({ hookName: 'some hook' })
test.addCommand({ hookId: 'h1' } as Command)
test.finish({ hookId: 'h1' })
expect(test.hooks[0].failed).to.be.true
})
it('does not throw error if hook does not exist', () => {
const test = new TestModel({} as TestProps, 0)
const test = new TestModel({ id: 1 } as TestProps, 0)
expect(() => {
test.finish({ hookName: 'some hook' })
test.finish({ hookId: 'h1' })
}).not.to.throw()
})
})
context('#commandMatchingErr', () => {
it('returns last command matching the error', () => {
const test = new TestModel({ err: { message: 'SomeError' } as Err } as TestProps, 0)
const test = new TestModel({ id: 1, err: { message: 'SomeError' } as Err, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.addCommand(new Command({ err: { message: 'SomeError' } as Err } as CommandProps), 'some hook')
test.addCommand(new Command({ err: {} as Err } as CommandProps), 'some hook')
test.addCommand(new Command({ err: { message: 'SomeError' } as Err } as CommandProps), 'some hook')
test.addCommand(new Command({ err: {} as Err } as CommandProps), 'another hook')
test.addCommand(new Command({ name: 'The One', err: { message: 'SomeError' } as Err } as CommandProps), 'another hook')
test.addCommand(new Command({ err: { message: 'SomeError' } as Err, hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ err: {} as Err, hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ err: { message: 'SomeError' } as Err, hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ err: {} as Err, hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ name: 'The One', err: { message: 'SomeError' } as Err, hookId: 'h1' } as CommandProps))
expect(test.commandMatchingErr()!.name).to.equal('The One')
})
it('returns undefined if there are no commands with errors', () => {
const test = new TestModel({ err: { message: 'SomeError' } as Err } as TestProps, 0)
const test = new TestModel({ id: 1, err: { message: 'SomeError' } as Err, hooks: [{ hookId: 'h1' } as HookProps] } as TestProps, 0)
test.addCommand(new Command({} as CommandProps), 'some hook')
test.addCommand(new Command({} as CommandProps), 'some hook')
test.addCommand(new Command({} as CommandProps), 'another hook')
test.addCommand(new Command({ hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ hookId: 'h1' } as CommandProps))
test.addCommand(new Command({ hookId: 'h1' } as CommandProps))
expect(test.commandMatchingErr()).to.be.undefined
})
})

View File

@@ -1,8 +1,9 @@
import _ from 'lodash'
import { action, autorun, computed, observable, observe } from 'mobx'
import { FileDetails } from '@packages/ui-components'
import Err from '../errors/err-model'
import Hook from '../hooks/hook-model'
import Hook, { HookName } from '../hooks/hook-model'
import Runnable, { RunnableProps } from '../runnables/runnable-model'
import Command, { CommandProps } from '../commands/command-model'
import Agent, { AgentProps } from '../agents/agent-model'
@@ -19,12 +20,13 @@ export interface TestProps extends RunnableProps {
agents?: Array<AgentProps>
commands?: Array<CommandProps>
routes?: Array<RouteProps>
invocationDetails?: FileDetails
}
export interface UpdatableTestProps {
state?: TestProps['state']
err?: TestProps['err']
hookName?: string
hookId?: string
isOpen?: TestProps['isOpen']
}
@@ -39,6 +41,15 @@ export default class Test extends Runnable {
@observable isOpen = false
@observable routes: Array<Route> = []
@observable _state?: TestState | null = null
@observable _invocationCount: number = 0
@observable invocationDetails?: FileDetails
@observable hookCount: { [name in HookName]: number } = {
'before all': 0,
'before each': 0,
'after all': 0,
'after each': 0,
'test body': 0,
}
type = 'test'
callbackAfterUpdate: (() => void) | null = null
@@ -49,6 +60,15 @@ export default class Test extends Runnable {
this._state = props.state
this.err.update(props.err)
this.invocationDetails = props.invocationDetails
this.hooks = _.map(props.hooks, (hook) => new Hook(hook))
this.hooks.push(new Hook({
hookId: this.id.toString(),
hookName: 'test body',
invocationDetails: this.invocationDetails,
}))
autorun(() => {
// if at any point, a command goes long running, set isLongRunning
// to true until the test becomes inactive
@@ -82,18 +102,36 @@ export default class Test extends Runnable {
this.routes.push(route)
}
addCommand (command: Command, hookName: string) {
const hook = this._findOrCreateHook(hookName)
addCommand (command: Command) {
this.commands.push(command)
const hookIndex = _.findIndex(this.hooks, { hookId: command.hookId })
const hook = this.hooks[hookIndex]
hook.addCommand(command)
// make sure that hooks are in order of invocation
if (hook.invocationOrder === undefined) {
hook.invocationOrder = this._invocationCount++
if (hook.invocationOrder !== hookIndex) {
this.hooks[hookIndex] = this.hooks[hook.invocationOrder]
this.hooks[hook.invocationOrder] = hook
}
}
// assign number if non existent
if (hook.hookNumber === undefined) {
hook.hookNumber = ++this.hookCount[hook.hookName]
}
}
start () {
this.isActive = true
}
update ({ state, err, hookName, isOpen }: UpdatableTestProps, cb?: UpdateTestCallback) {
update ({ state, err, hookId, isOpen }: UpdatableTestProps, cb?: UpdateTestCallback) {
let hadChanges = false
const disposer = observe(this, (change) => {
@@ -118,8 +156,8 @@ export default class Test extends Runnable {
this.isOpen = isOpen
}
if (hookName) {
const hook = _.find(this.hooks, { name: hookName })
if (hookId) {
const hook = _.find(this.hooks, { hookId })
if (hook) {
hook.failed = true
@@ -154,16 +192,4 @@ export default class Test extends Runnable {
.compact()
.last()
}
_findOrCreateHook (name: string) {
const hook = _.find(this.hooks, { name })
if (hook) return hook
const newHook = new Hook({ name })
this.hooks.push(newHook)
return newHook
}
}

View File

@@ -21,6 +21,7 @@ const model = (props?: Partial<TestModel>) => {
return _.extend<TestModel>({
agents: [],
commands: [],
hooks: [],
err: {},
id: 't1',
isActive: true,
@@ -112,13 +113,13 @@ describe('<Test />', () => {
})
it('renders <Hooks /> if there are commands', () => {
const component = mount(<Test model={model({ commands: [{ id: 1 }], state: 'failed' } as TestModel)} />)
const component = shallow(<Test model={model({ commands: [{ id: 1, hookId: 'h1' }], hooks: [{ hookId: 'h1' }], state: 'failed' } as TestModel)} />)
expect(component.find(Hooks)).to.exist
})
it('renders <NoCommands /> is no commands', () => {
const component = mount(<Test model={model({ state: 'failed' })} />)
const component = shallow(<Test model={model({ state: 'failed' })} />)
expect(component.find(NoCommands)).to.exist
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
describe('my test', () => {
before(() => {
cy.log('beforeHook 1')
})
beforeEach(() => {
cy.log('beforeEachHook 1')
})
it('tests 1', () => {
cy.log('testBody 1')
})
describe('nested suite', () => {
before(() => {
cy.log('beforeHook 2')
})
before(() => {
cy.log('beforeHook 3')
})
beforeEach(() => {
cy.log('beforeEachHook 2')
})
it('tests 2', () => {
cy.log('testBody 2')
})
afterEach(() => {
cy.log('afterEachHook 2')
})
after(() => {
cy.log('afterHook 2')
})
})
afterEach(() => {
cy.log('afterEachHook 1')
})
after(() => {
cy.log('afterHook 1')
})
})

View File

@@ -0,0 +1,70 @@
const helpers = require('../support/helpers')
const { createCypress } = helpers
const { runIsolatedCypress } = createCypress()
describe('hooks', function () {
beforeEach(function () {
this.editor = {}
return runIsolatedCypress(`cypress/fixtures/hook_spec.js`, {
onBeforeRun: ({ win }) => {
this.win = win
win.runnerWs.emit.withArgs('get:user:editor')
.yields({
preferredOpener: this.editor,
})
},
})
})
it('displays commands under correct hook', function () {
cy.contains('tests 1').click()
cy.contains('before all').closest('.collapsible').should('contain', 'beforeHook 1')
cy.contains('before each').closest('.collapsible').should('contain', 'beforeEachHook 1')
cy.contains('test body').closest('.collapsible').should('contain', 'testBody 1')
cy.contains('after each').closest('.collapsible').should('contain', 'afterEachHook 1')
})
it('displays hooks without number when only one of type', function () {
cy.contains('tests 1').click()
cy.contains('before all').should('not.contain', '(1)')
cy.contains('before each').should('not.contain', '(1)')
cy.contains('after each').should('not.contain', '(1)')
})
it('displays hooks separately with number when more than one of type', function () {
cy.contains('tests 2').click()
cy.contains('before all (1)').closest('.collapsible').should('contain', 'beforeHook 2')
cy.contains('before all (2)').closest('.collapsible').should('contain', 'beforeHook 3')
cy.contains('before each (1)').closest('.collapsible').should('contain', 'beforeEachHook 1')
cy.contains('before each (2)').closest('.collapsible').should('contain', 'beforeEachHook 2')
cy.contains('test body').closest('.collapsible').should('contain', 'testBody 2')
cy.contains('after each (1)').closest('.collapsible').should('contain', 'afterEachHook 2')
cy.contains('after each (2)').closest('.collapsible').should('contain', 'afterEachHook 1')
cy.contains('after all (1)').closest('.collapsible').should('contain', 'afterHook 2')
cy.contains('after all (2)').closest('.collapsible').should('contain', 'afterHook 1')
})
it('creates open in IDE button', function () {
cy.contains('tests 1').click()
cy.get('.hook-open-in-ide').should('have.length', 4)
})
it('properly opens file in IDE at hook', function () {
cy.contains('tests 1').click()
cy.contains('Open in IDE').invoke('show').click().then(function () {
expect(this.win.runnerWs.emit.withArgs('open:file').lastCall.args[1].file).to.include('hook_spec.js')
// chrome sets the column to right before "before("
// while firefox sets it right after "before("
expect(this.win.runnerWs.emit.withArgs('open:file').lastCall.args[1].column).to.be.eq(Cypress.browser.family === 'firefox' ? 10 : 3)
expect(this.win.runnerWs.emit.withArgs('open:file').lastCall.args[1].line).to.be.eq(2)
})
})
})

View File

@@ -17,6 +17,7 @@ const eventCleanseMap = {
tests: stringifyShort,
commands: stringifyShort,
err: stringifyShort,
invocationDetails: stringifyShort,
body: '[body]',
wallClockStartedAt: match.date,
lifecycle: match.number,
@@ -439,6 +440,7 @@ const cleanseRunStateMap = {
'err.stack': '[err stack]',
sourceMappedStack: match.string,
parsedStack: match.array,
invocationDetails: stringifyShort,
}
const shouldHaveTestResults = (expPassed, expFailed) => {

View File

@@ -422,7 +422,7 @@ exports['e2e spec isolation fails'] = {
"body": "function () {\n return cy.wait(200);\n }"
},
{
"hookId": "h4",
"hookId": "h5",
"hookName": "after each",
"title": [
"\"after each\" hook"
@@ -430,7 +430,7 @@ exports['e2e spec isolation fails'] = {
"body": "function () {\n return cy.wait(200);\n }"
},
{
"hookId": "h5",
"hookId": "h4",
"hookName": "after all",
"title": [
"\"after all\" hook"
@@ -476,7 +476,7 @@ exports['e2e spec isolation fails'] = {
},
"after each": [
{
"hookId": "h4",
"hookId": "h5",
"fnDuration": 400,
"afterFnDuration": 200
}
@@ -512,7 +512,7 @@ exports['e2e spec isolation fails'] = {
},
"after each": [
{
"hookId": "h4",
"hookId": "h5",
"fnDuration": 400,
"afterFnDuration": 200
}
@@ -548,14 +548,14 @@ exports['e2e spec isolation fails'] = {
},
"after each": [
{
"hookId": "h4",
"hookId": "h5",
"fnDuration": 400,
"afterFnDuration": 200
}
],
"after all": [
{
"hookId": "h5",
"hookId": "h4",
"fnDuration": 400,
"afterFnDuration": 200
}