chore: refactor cy funcs (#19080)

Co-authored-by: Emily Rohrbough <emilyrohrbough@users.noreply.github.com>
Co-authored-by: David Munechika <david@cypress.io>
This commit is contained in:
Kukhyeon Heo
2021-12-09 01:36:10 +09:00
committed by GitHub
parent 3a21ee48c4
commit d18878feea
6 changed files with 1190 additions and 1224 deletions

View File

@@ -1,6 +1,6 @@
import _ from 'lodash'
import $Command from '../../../src/cypress/command'
import $CommandQueue from '../../../src/cypress/command_queue'
import { CommandQueue } from '../../../src/cypress/command_queue'
const createCommand = (props = {}) => {
return $Command.create(_.extend({
@@ -23,14 +23,14 @@ const log = (props = {}) => {
describe('src/cypress/command_queue', () => {
let queue
const state = () => {}
const timeouts = { timeout () {} }
const stability = { whenStable () {} }
const timeout = () => {}
const whenStable = () => {}
const cleanup = () => {}
const fail = () => {}
const isCy = () => {}
beforeEach(() => {
queue = $CommandQueue.create(state, timeouts, stability, cleanup, fail, isCy)
queue = new CommandQueue(state, timeout, whenStable, cleanup, fail, isCy)
queue.add(createCommand({
name: 'get',

View File

@@ -1,6 +1,6 @@
import Bluebird from 'bluebird'
import $Queue from '../../../src/util/queue'
import { Queue } from '../../../src/util/queue'
const ids = (queueables) => queueables.map((q) => q.id)
@@ -8,7 +8,7 @@ describe('src/util/queue', () => {
let queue
beforeEach(() => {
queue = $Queue.create([
queue = new Queue([
{ id: '1' },
{ id: '2' },
{ id: '3' },

View File

@@ -14,7 +14,7 @@ import browserInfo from './cypress/browser'
import $scriptUtils from './cypress/script_utils'
import $Commands from './cypress/commands'
import $Cy from './cypress/cy'
import { $Cy } from './cypress/cy'
import $dom from './dom'
import $Downloads from './cypress/downloads'
import $errorMessages from './cypress/error_messages'
@@ -209,12 +209,8 @@ class $Cypress {
// or parsed. we have not received any custom commands
// at this point
onSpecWindow (specWindow, scripts) {
const logFn = (...args) => {
return this.log.apply(this, args)
}
// create cy and expose globally
this.cy = $Cy.create(specWindow, this, this.Cookies, this.state, this.config, logFn)
this.cy = new $Cy(specWindow, this, this.Cookies, this.state, this.config)
window.cy = this.cy
this.isCy = this.cy.isCy
this.log = $Log.create(this, this.cy, this.state, this.config)

View File

@@ -3,7 +3,7 @@ import $ from 'jquery'
import Bluebird from 'bluebird'
import Debug from 'debug'
import $queue from '../util/queue'
import { Queue } from '../util/queue'
import $dom from '../dom'
import $utils from './utils'
import $errUtils from './error_utils'
@@ -60,336 +60,322 @@ const commandRunningFailed = (Cypress, state, err) => {
})
}
export default {
create: (state, timeout, whenStable, cleanup, fail, isCy) => {
const queue = $queue.create()
export class CommandQueue extends Queue<Command> {
state: any
timeout: any
whenStable: any
cleanup: any
fail: any
isCy: any
const { get, slice, at, reset, clear, stop } = queue
constructor (state, timeout, whenStable, cleanup, fail, isCy) {
super()
this.state = state
this.timeout = timeout
this.whenStable = whenStable
this.cleanup = cleanup
this.fail = fail
this.isCy = isCy
}
const logs = (filter) => {
let logs = _.flatten(_.invokeMap(queue.get(), 'get', 'logs'))
logs (filter) {
let logs = _.flatten(_.invokeMap(this.get(), 'get', 'logs'))
if (filter) {
const matchesFilter = _.matches(filter)
if (filter) {
const matchesFilter = _.matches(filter)
logs = _.filter(logs, (log) => {
return matchesFilter(log.get())
})
}
return logs
}
const names = () => {
return _.invokeMap(queue.get(), 'get', 'name')
}
const add = (command) => {
queue.add(command)
}
const insert = (index: number, command: Command) => {
queue.insert(index, command)
const prev = at(index - 1) as Command
const next = at(index + 1) as Command
if (prev) {
prev.set('next', command)
command.set('prev', prev)
}
if (next) {
next.set('prev', command)
command.set('next', next)
}
return command
}
const find = (attrs) => {
const matchesAttrs = _.matches(attrs)
return _.find(queue.get(), (command: Command) => {
return matchesAttrs(command.attributes)
logs = _.filter(logs, (log) => {
return matchesFilter(log.get())
})
}
const runCommand = (command: Command) => {
// bail here prior to creating a new promise
// because we could have stopped / canceled
// prior to ever making it through our first
// command
if (queue.stopped) {
return logs
}
names () {
return _.invokeMap(this.get(), 'get', 'name')
}
insert (index: number, command: Command) {
super.insert(index, command)
const prev = this.at(index - 1)
const next = this.at(index + 1)
if (prev) {
prev.set('next', command)
command.set('prev', prev)
}
if (next) {
next.set('prev', command)
command.set('next', next)
}
return command
}
find (attrs) {
const matchesAttrs = _.matches(attrs)
return _.find(this.get(), (command: Command) => {
return matchesAttrs(command.attributes)
})
}
private runCommand (command: Command) {
// bail here prior to creating a new promise
// because we could have stopped / canceled
// prior to ever making it through our first
// command
if (this.stopped) {
return
}
this.state('current', command)
this.state('chainerId', command.get('chainerId'))
return this.whenStable(() => {
this.state('nestedIndex', this.state('index'))
return command.get('args')
})
.then((args) => {
// store this if we enqueue new commands
// to check for promise violations
let ret
let enqueuedCmd
const commandEnqueued = (obj) => {
return enqueuedCmd = obj
}
// only check for command enqueuing when none
// of our args are functions else commands
// like cy.then or cy.each would always fail
// since they return promises and queue more
// new commands
if ($utils.noArgsAreAFunction(args)) {
Cypress.once('command:enqueued', commandEnqueued)
}
// run the command's fn with runnable's context
try {
ret = __stackReplacementMarker(command.get('fn'), this.state('ctx'), args)
} catch (err) {
throw err
} finally {
// always remove this listener
Cypress.removeListener('command:enqueued', commandEnqueued)
}
this.state('commandIntermediateValue', ret)
// we cannot pass our cypress instance or our chainer
// back into bluebird else it will create a thenable
// which is never resolved
if (this.isCy(ret)) {
return null
}
if (!(!enqueuedCmd || !$utils.isPromiseLike(ret))) {
$errUtils.throwErrByPath(
'miscellaneous.command_returned_promise_and_commands', {
args: {
current: command.get('name'),
called: enqueuedCmd.name,
},
},
)
}
if (!(!enqueuedCmd || !!_.isUndefined(ret))) {
ret = _.isFunction(ret) ?
ret.toString() :
$utils.stringify(ret)
// if we got a return value and we enqueued
// a new command and we didn't return cy
// or an undefined value then throw
return $errUtils.throwErrByPath(
'miscellaneous.returned_value_and_commands_from_custom_command', {
args: {
current: command.get('name'),
returned: ret,
},
},
)
}
return ret
}).then((subject) => {
this.state('commandIntermediateValue', undefined)
// we may be given a regular array here so
// we need to re-wrap the array in jquery
// if that's the case if the first item
// in this subject is a jquery element.
// we want to do this because in 3.1.2 there
// was a regression when wrapping an array of elements
const firstSubject = $utils.unwrapFirst(subject)
// if ret is a DOM element and its not an instance of our own jQuery
if (subject && $dom.isElement(firstSubject) && !$utils.isInstanceOf(subject, $)) {
// set it back to our own jquery object
// to prevent it from being passed downstream
// TODO: enable turning this off
// wrapSubjectsInJquery: false
// which will just pass subjects downstream
// without modifying them
subject = $dom.wrap(subject)
}
command.set({ subject })
// end / snapshot our logs if they need it
command.finishLogs()
// reset the nestedIndex back to null
this.state('nestedIndex', null)
// also reset recentlyReady back to null
this.state('recentlyReady', null)
// we're finished with the current command so set it back to null
this.state('current', null)
this.state('subject', subject)
return subject
})
}
// TypeScript doesn't allow overriding functions with different type signatures
// @ts-ignore
run () {
const next = () => {
// bail if we've been told to abort in case
// an old command continues to run after
if (this.stopped) {
return
}
state('current', command)
state('chainerId', command.get('chainerId'))
// start at 0 index if we dont have one
let index = this.state('index') || this.state('index', 0)
return whenStable(() => {
state('nestedIndex', state('index'))
const command = this.at(index)
return command.get('args')
})
.then((args) => {
// store this if we enqueue new commands
// to check for promise violations
let ret
let enqueuedCmd
// if the command should be skipped
// just bail and increment index
// and set the subject
if (command && command.get('skip')) {
// must set prev + next since other
// operations depend on this state being correct
command.set({
prev: this.at(index - 1),
next: this.at(index + 1),
})
const commandEnqueued = (obj) => {
return enqueuedCmd = obj
}
this.state('index', index + 1)
this.state('subject', command.get('subject'))
// only check for command enqueuing when none
// of our args are functions else commands
// like cy.then or cy.each would always fail
// since they return promises and queue more
// new commands
if ($utils.noArgsAreAFunction(args)) {
Cypress.once('command:enqueued', commandEnqueued)
}
return next()
}
// run the command's fn with runnable's context
try {
ret = __stackReplacementMarker(command.get('fn'), state('ctx'), args)
} catch (err) {
throw err
} finally {
// always remove this listener
Cypress.removeListener('command:enqueued', commandEnqueued)
}
// if we're at the very end
if (!command) {
// trigger queue is almost finished
Cypress.action('cy:command:queue:before:end')
state('commandIntermediateValue', ret)
// we need to wait after all commands have
// finished running if the application under
// test is no longer stable because we cannot
// move onto the next test until its finished
return this.whenStable(() => {
Cypress.action('cy:command:queue:end')
// we cannot pass our cypress instance or our chainer
// back into bluebird else it will create a thenable
// which is never resolved
if (isCy(ret)) {
return null
}
if (!(!enqueuedCmd || !$utils.isPromiseLike(ret))) {
return $errUtils.throwErrByPath(
'miscellaneous.command_returned_promise_and_commands', {
args: {
current: command.get('name'),
called: enqueuedCmd.name,
},
},
)
}
if (!(!enqueuedCmd || !!_.isUndefined(ret))) {
ret = _.isFunction(ret) ?
ret.toString() :
$utils.stringify(ret)
// if we got a return value and we enqueued
// a new command and we didn't return cy
// or an undefined value then throw
return $errUtils.throwErrByPath(
'miscellaneous.returned_value_and_commands_from_custom_command', {
args: {
current: command.get('name'),
returned: ret,
},
},
)
}
return ret
}).then((subject) => {
state('commandIntermediateValue', undefined)
// we may be given a regular array here so
// we need to re-wrap the array in jquery
// if that's the case if the first item
// in this subject is a jquery element.
// we want to do this because in 3.1.2 there
// was a regression when wrapping an array of elements
const firstSubject = $utils.unwrapFirst(subject)
// if ret is a DOM element and its not an instance of our own jQuery
if (subject && $dom.isElement(firstSubject) && !$utils.isInstanceOf(subject, $)) {
// set it back to our own jquery object
// to prevent it from being passed downstream
// TODO: enable turning this off
// wrapSubjectsInJquery: false
// which will just pass subjects downstream
// without modifying them
subject = $dom.wrap(subject)
}
command.set({ subject })
// end / snapshot our logs if they need it
command.finishLogs()
// reset the nestedIndex back to null
state('nestedIndex', null)
// also reset recentlyReady back to null
state('recentlyReady', null)
// we're finished with the current command so set it back to null
state('current', null)
state('subject', subject)
return subject
})
}
const run = () => {
const next = () => {
// bail if we've been told to abort in case
// an old command continues to run after
if (queue.stopped) {
return
}
// start at 0 index if we dont have one
let index = state('index') || state('index', 0)
const command = at(index) as Command
// if the command should be skipped
// just bail and increment index
// and set the subject
if (command && command.get('skip')) {
// must set prev + next since other
// operations depend on this state being correct
command.set({
prev: at(index - 1) as Command,
next: at(index + 1) as Command,
})
state('index', index + 1)
state('subject', command.get('subject'))
return next()
}
// if we're at the very end
if (!command) {
// trigger queue is almost finished
Cypress.action('cy:command:queue:before:end')
// we need to wait after all commands have
// finished running if the application under
// test is no longer stable because we cannot
// move onto the next test until its finished
return whenStable(() => {
Cypress.action('cy:command:queue:end')
return null
})
}
// store the previous timeout
const prevTimeout = timeout()
// store the current runnable
const runnable = state('runnable')
Cypress.action('cy:command:start', command)
return runCommand(command)
.then(() => {
// each successful command invocation should
// always reset the timeout for the current runnable
// unless it already has a state. if it has a state
// and we reset the timeout again, it will always
// cause a timeout later no matter what. by this time
// mocha expects the test to be done
let fn
if (!runnable.state) {
timeout(prevTimeout)
}
// mutate index by incrementing it
// this allows us to keep the proper index
// in between different hooks like before + beforeEach
// else run will be called again and index would start
// over at 0
index += 1
state('index', index)
Cypress.action('cy:command:end', command)
fn = state('onPaused')
if (fn) {
return new Bluebird((resolve) => {
return fn(resolve)
}).then(next)
}
return next()
})
}
const onError = (err: Error | string) => {
if (state('onCommandFailed')) {
return state('onCommandFailed')(err, queue, next)
// store the previous timeout
const prevTimeout = this.timeout()
// store the current runnable
const runnable = this.state('runnable')
Cypress.action('cy:command:start', command)
return this.runCommand(command)
.then(() => {
// each successful command invocation should
// always reset the timeout for the current runnable
// unless it already has a state. if it has a state
// and we reset the timeout again, it will always
// cause a timeout later no matter what. by this time
// mocha expects the test to be done
let fn
if (!runnable.state) {
this.timeout(prevTimeout)
}
debugErrors('caught error in promise chain: %o', err)
// mutate index by incrementing it
// this allows us to keep the proper index
// in between different hooks like before + beforeEach
// else run will be called again and index would start
// over at 0
index += 1
this.state('index', index)
// since this failed this means that a specific command failed
// and we should highlight it in red or insert a new command
// @ts-ignore
if (_.isObject(err) && !err.name) {
// @ts-ignore
err.name = 'CypressError'
Cypress.action('cy:command:end', command)
fn = this.state('onPaused')
if (fn) {
return new Bluebird((resolve) => {
return fn(resolve)
}).then(next)
}
commandRunningFailed(Cypress, state, err)
return next()
})
}
return fail(err)
const onError = (err: Error | string) => {
if (this.state('onCommandFailed')) {
return this.state('onCommandFailed')(err, this, next)
}
const { promise, reject, cancel } = queue.run({
onRun: next,
onError,
onFinish: cleanup,
})
debugErrors('caught error in promise chain: %o', err)
state('promise', promise)
state('reject', reject)
state('cancel', () => {
cancel()
// since this failed this means that a specific command failed
// and we should highlight it in red or insert a new command
// @ts-ignore
if (_.isObject(err) && !err.name) {
// @ts-ignore
err.name = 'CypressError'
}
Cypress.action('cy:canceled')
})
commandRunningFailed(Cypress, this.state, err)
return promise
return this.fail(err)
}
return {
logs,
names,
add,
insert,
find,
run,
get,
slice,
at,
reset,
clear,
stop,
const { promise, reject, cancel } = super.run({
onRun: next,
onError,
onFinish: this.cleanup,
})
get length () {
return queue.length
},
this.state('promise', promise)
this.state('reject', reject)
this.state('cancel', () => {
cancel()
get stopped () {
return queue.stopped
},
}
},
Cypress.action('cy:canceled')
})
return promise
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,111 +6,102 @@ interface QueueRunProps {
onFinish: () => void
}
export default {
create: <T>(queueables: T[] = []) => {
let stopped = false
export class Queue<T> {
private queueables: T[] = []
private _stopped = false
const get = (): T[] => {
return queueables
constructor (queueables: T[] = []) {
this.queueables = queueables
}
get (): T[] {
return this.queueables
}
add (queueable: T) {
this.queueables.push(queueable)
}
insert (index: number, queueable: T) {
if (index < 0 || index > this.queueables.length) {
throw new Error(`queue.insert must be called with a valid index - the index (${index}) is out of bounds`)
}
const add = (queueable: T) => {
queueables.push(queueable)
}
this.queueables.splice(index, 0, queueable)
const insert = (index: number, queueable: T) => {
if (index < 0 || index > queueables.length) {
throw new Error(`queue.insert must be called with a valid index - the index (${index}) is out of bounds`)
}
return queueable
}
queueables.splice(index, 0, queueable)
slice (index: number) {
return this.queueables.slice(index)
}
return queueable
}
at (index: number): T {
return this.queueables[index]
}
const slice = (index: number) => {
return queueables.slice(index)
}
reset () {
this._stopped = false
}
const at = (index: number): T => {
return get()[index]
}
clear () {
this.queueables.length = 0
}
const reset = () => {
stopped = false
}
stop () {
this._stopped = true
}
const clear = () => {
queueables.length = 0
}
run ({ onRun, onError, onFinish }: QueueRunProps) {
let inner
let rejectOuterAndCancelInner
const stop = () => {
stopped = true
}
// this ends up being the parent promise wrapper
const promise = new Bluebird((resolve, reject) => {
// bubble out the inner promise. we must use a resolve(null) here
// so the outer promise is first defined else this will kick off
// the 'next' call too soon and end up running commands prior to
// the promise being defined
inner = Bluebird
.resolve(null)
.then(onRun)
.then(resolve)
.catch(reject)
const run = ({ onRun, onError, onFinish }: QueueRunProps) => {
let inner
let rejectOuterAndCancelInner
// this ends up being the parent promise wrapper
const promise = new Bluebird((resolve, reject) => {
// bubble out the inner promise. we must use a resolve(null) here
// so the outer promise is first defined else this will kick off
// the 'next' call too soon and end up running commands prior to
// the promise being defined
inner = Bluebird
.resolve(null)
.then(onRun)
.then(resolve)
.catch(reject)
// can't use onCancel argument here because it's called asynchronously.
// when we manually reject our outer promise we have to immediately
// cancel the inner one else it won't be notified and its callbacks
// will continue to be invoked. normally we don't have to do this
// because rejections come from the inner promise and bubble out to
// our outer, but when we manually reject the outer promise, we
// have to go in the opposite direction from outer -> inner
rejectOuterAndCancelInner = (err) => {
inner.cancel()
reject(err)
}
})
.catch(onError)
.finally(onFinish)
const cancel = () => {
promise.cancel()
// can't use onCancel argument here because it's called asynchronously.
// when we manually reject our outer promise we have to immediately
// cancel the inner one else it won't be notified and its callbacks
// will continue to be invoked. normally we don't have to do this
// because rejections come from the inner promise and bubble out to
// our outer, but when we manually reject the outer promise, we
// have to go in the opposite direction from outer -> inner
rejectOuterAndCancelInner = (err) => {
inner.cancel()
reject(err)
}
})
.catch(onError)
.finally(onFinish)
return {
promise,
cancel,
// wrapped to ensure `rejectOuterAndCancelInner` is assigned
// before reject is called
reject: (err) => rejectOuterAndCancelInner(err),
}
const cancel = () => {
promise.cancel()
inner.cancel()
}
return {
get,
add,
insert,
slice,
at,
reset,
clear,
stop,
run,
get length () {
return queueables.length
},
get stopped () {
return stopped
},
promise,
cancel,
// wrapped to ensure `rejectOuterAndCancelInner` is assigned
// before reject is called
reject: (err) => rejectOuterAndCancelInner(err),
}
},
}
get length () {
return this.queueables.length
}
get stopped () {
return this._stopped
}
}