codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
402 lines (366 loc) • 9.06 kB
JavaScript
const debug = require('debug')('codeceptjs:recorder')
const promiseRetry = require('promise-retry')
const chalk = require('chalk')
const { printObjectProperties } = require('./utils')
const { log } = require('./output')
const { TimeoutError } = require('./timeout')
const MAX_TASKS = 100
let promise
let running = false
let errFn
let queueId = 0
let sessionId = null
let asyncErr = null
let ignoredErrs = []
let tasks = []
let oldPromises = []
const defaultRetryOptions = {
retries: 0,
minTimeout: 150,
maxTimeout: 10000,
}
/**
* Singleton object to record all test steps as promises and run them in chain.
* @alias recorder
* @interface
*/
module.exports = {
/**
* @type {Array<Object<string, *>>}
* @inner
*/
retries: [],
/**
* Start recording promises
*
* @api
* @inner
*/
start() {
debug('Starting recording promises')
running = true
asyncErr = null
errFn = null
this.reset()
},
/**
* @return {boolean}
* @inner
*/
isRunning() {
return running
},
/**
* @return {void}
* @inner
*/
startUnlessRunning() {
if (!this.isRunning()) {
this.start()
}
},
/**
* Add error handler to catch rejected promises
*
* @api
* @param {function} fn
* @inner
*/
errHandler(fn) {
errFn = fn
},
/**
* Stops current promise chain, calls `catch`.
* Resets recorder to initial state.
*
* @api
* @inner
*/
reset() {
if (promise && running) this.catch()
queueId++
sessionId = null
asyncErr = null
log(`${currentQueue()} Starting recording promises`)
promise = Promise.resolve()
oldPromises = []
tasks = []
ignoredErrs = []
this.session.running = false
// reset this retries makes the retryFailedStep plugin won't work if there is Before/BeforeSuit block due to retries is undefined on Scenario
// this.retries = [];
},
/**
* @name CodeceptJS.recorder~session
* @type {CodeceptJS.RecorderSession}
* @inner
*/
/**
* @alias RecorderSession
* @interface
*/
session: {
/**
* @type {boolean}
* @inner
*/
running: false,
/**
* @param {string} name
* @inner
*/
start(name) {
if (sessionId) {
debug(`${currentQueue()}Session already started as ${sessionId}`)
this.restore(sessionId)
}
debug(`${currentQueue()}Starting <${name}> session`)
tasks.push('--->')
oldPromises.push(promise)
this.running = true
sessionId = name
promise = Promise.resolve()
},
/**
* @param {string} [name]
* @inner
*/
restore(name) {
tasks.push('<---')
debug(`${currentQueue()}Finalize <${name}> session`)
this.running = false
sessionId = null
this.catch(errFn)
promise = promise.then(() => oldPromises.pop())
},
/**
* @param {function} fn
* @inner
*/
catch(fn) {
promise = promise.catch(fn)
},
},
/**
* Adds a promise to a chain.
* Promise description should be passed as first parameter.
*
* @param {string|function} taskName
* @param {function} [fn]
* @param {boolean} [force=false]
* @param {boolean} [retry]
* undefined: `add(fn)` -> `false` and `add('step',fn)` -> `true`
* true: it will retries if `retryOpts` set.
* false: ignore `retryOpts` and won't retry.
* @param {number} [timeout]
* @return {Promise<*>}
* @inner
*/
add(taskName, fn = undefined, force = false, retry = undefined, timeout = undefined) {
if (typeof taskName === 'function') {
fn = taskName
taskName = fn.toString()
if (retry === undefined) retry = false
}
if (retry === undefined) retry = true
if (!running && !force) {
return Promise.resolve()
}
tasks.push(taskName)
debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`))
return (promise = Promise.resolve(promise).then(res => {
// prefer options for non-conditional retries
const retryOpts = this.retries
.sort((r1, r2) => r1.when && !r2.when)
.slice(-1)
.pop()
// no retries or unnamed tasks
debug(`${currentQueue()} Running | ${taskName} | Timeout: ${timeout || 'None'}`)
if (retryOpts) debug(`${currentQueue()} Retry opts`, JSON.stringify(retryOpts))
if (!retryOpts || !taskName || !retry) {
const [promise, timer] = getTimeoutPromise(timeout, taskName)
return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer))
}
const retryRules = this.retries.slice().reverse()
return promiseRetry(Object.assign(defaultRetryOptions, retryOpts), (retry, number) => {
if (number > 1) log(`${currentQueue()}Retrying... Attempt #${number}`)
const [promise, timer] = getTimeoutPromise(timeout, taskName)
return Promise.race([promise, Promise.resolve(res).then(fn)])
.finally(() => clearTimeout(timer))
.catch(err => {
if (ignoredErrs.includes(err)) return
for (const retryObj of retryRules) {
if (!retryObj.when) return retry(err)
if (retryObj.when && retryObj.when(err)) return retry(err)
}
throw err
})
})
}))
},
/**
* @param {*} opts
* @return {*}
* @inner
*/
retry(opts) {
if (!promise) return
if (opts === null) {
opts = {}
}
if (Number.isInteger(opts)) {
opts = { retries: opts }
}
return this.add(() => this.retries.push(opts))
},
/**
* @param {function} [customErrFn]
* @return {Promise<*>}
* @inner
*/
catch(customErrFn) {
const fnDescription = customErrFn
?.toString()
?.replace(/\s{2,}/g, ' ')
.replace(/\n/g, ' ')
?.slice(0, 50)
debug(chalk.gray(`${currentQueue()} Queued | catch with error handler ${fnDescription || ''}`))
return (promise = promise.catch(err => {
log(`${currentQueue()}Error | ${err} ${fnDescription}...`)
if (!(err instanceof Error)) {
// strange things may happen
err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`) // we should be prepared for them
}
if (customErrFn) {
customErrFn(err)
} else if (errFn) {
errFn(err)
}
this.stop()
}))
},
/**
* @param {function} customErrFn
* @return {Promise<*>}
* @inner
*/
catchWithoutStop(customErrFn) {
const fnDescription = customErrFn
?.toString()
?.replace(/\s{2,}/g, ' ')
.replace(/\n/g, ' ')
?.slice(0, 50)
return (promise = promise.catch(err => {
if (ignoredErrs.includes(err)) return // already caught
log(`${currentQueue()} Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`)
if (!(err instanceof Error)) {
// strange things may happen
err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`) // we should be prepared for them
}
if (customErrFn) {
return customErrFn(err)
}
}))
},
/**
* Adds a promise which throws an error into a chain
*
* @api
* @param {*} err
* @inner
*/
throw(err) {
if (ignoredErrs.includes(err)) return promise // already caught
return this.add(`throw error: ${err.message}`, () => {
if (ignoredErrs.includes(err)) return // already caught
throw err
})
},
ignoreErr(err) {
ignoredErrs.push(err)
},
/**
* @param {*} err
* @inner
*/
saveFirstAsyncError(err) {
if (asyncErr === null) {
asyncErr = err
}
},
/**
* @return {*}
* @inner
*/
getAsyncErr() {
return asyncErr
},
/**
* @return {void}
* @inner
*/
cleanAsyncErr() {
asyncErr = null
},
/**
* Stops recording promises
* @api
* @inner
*/
stop() {
debug(this.toString())
log(`${currentQueue()} Stopping recording promises`)
running = false
},
/**
* Get latest promise in chain.
*
* @api
* @return {Promise<*>}
* @inner
*/
promise() {
return promise
},
/**
* Get a list of all chained tasks
* @return {string}
* @inner
*/
scheduled() {
return tasks.slice(-MAX_TASKS).join('\n')
},
/**
* Get the queue id
* @return {number}
* @inner
*/
getQueueId() {
return queueId
},
/**
* Get a state of current queue and tasks
* @return {string}
* @inner
*/
toString() {
return `Queue: ${currentQueue()}\n\nTasks: ${this.scheduled()}`
},
}
function getTimeoutPromise(timeoutMs, taskName) {
let timer
if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`)
return [
new Promise((done, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(`Action ${taskName} was interrupted on timeout ${timeoutMs}ms`))
}, timeoutMs || 2e9)
}),
timer,
]
}
function currentQueue() {
let session = ''
if (sessionId) session = `<${sessionId}> `
return `[${queueId}] ${session}`
}