@revoloo/cypress6
Version:
Cypress.io end to end testing tool
540 lines (424 loc) • 13.5 kB
JavaScript
/* globals Cypress */
const _ = require('lodash')
const Promise = require('bluebird')
const $utils = require('../../cypress/utils')
const $errUtils = require('../../cypress/error_utils')
const $stackUtils = require('../../cypress/stack_utils')
const $Server = require('../../cypress/server')
const $Location = require('../../cypress/location')
let server = null
const tryDecodeUri = (uri) => {
try {
return decodeURI(uri)
} catch (error) {
return uri
}
}
const cancelPendingXhrs = () => server ? server.cancelPendingXhrs() : null
const reset = function () {
if (server) {
server.restore()
}
server = null
}
const isUrlLikeArgs = (url, response) => {
return (!_.isObject(url) && !_.isObject(response)) ||
(_.isRegExp(url) || _.isString(url))
}
const getUrl = (options) => {
return options.originalUrl || options.url
}
const unavailableErr = () => {
return $errUtils.throwErrByPath('server.unavailable')
}
const getDisplayName = (route) => _.isNil(route?.response) ? 'xhr' : 'xhr stub'
const stripOrigin = (url) => {
const location = $Location.create(url)
return url.replace(location.origin, '')
}
const getXhrServer = (state) => state('server') || unavailableErr()
const setRequest = (state, xhr, alias) => {
const requests = state('requests') || []
requests.push({
xhr,
alias,
})
return state('requests', requests)
}
const setResponse = (state, xhr) => {
const obj = _.find(state('requests'), { xhr })
// if we've been reset between tests and an xhr
// leaked through, then we may not be able to associate
// this response correctly
if (!obj) {
return
}
const index = state('requests').indexOf(obj)
const responses = state('responses') || []
// set the response in the same index as the request
// so we can later wait on this specific index'd response
// else its not deterministic
responses[index] = {
xhr,
alias: obj?.alias,
}
return state('responses', responses)
}
const startXhrServer = (cy, state, config) => {
const logs = {}
server = $Server.create({
xhrUrl: config('xhrUrl'),
stripOrigin,
// shouldnt these stubs be called routes?
// rename everything related to stubs => routes
onSend: (xhr, stack, route) => {
const alias = route?.alias
setRequest(state, xhr, alias)
const rl = route && route.log
if (rl) {
const numResponses = rl.get('numResponses')
rl.set('numResponses', numResponses + 1)
}
const log = logs[xhr.id] = Cypress.log({
message: '',
name: 'xhr',
displayName: getDisplayName(route),
alias,
aliasType: 'route',
type: 'parent',
event: true,
timeout: 0,
consoleProps: () => {
const consoleObj = {
Alias: alias,
Method: xhr.method,
URL: xhr.url,
'Matched URL': route?.url,
Status: xhr.statusMessage,
Duration: xhr.duration,
Stubbed: _.isNil(route?.response) ? 'No' : 'Yes',
Request: xhr.request,
Response: xhr.response,
XHR: xhr._getXhr(),
}
if (route && route.is404) {
consoleObj.Note = 'This request did not match any of your routes. It was automatically sent back \'404\'. Setting cy.server({force404: false}) will turn off this behavior.'
}
consoleObj.groups = () => {
return [
{
name: 'Initiator',
items: [stack],
label: false,
},
]
}
return consoleObj
},
renderProps () {
let indicator
let status
if (xhr.aborted) {
indicator = 'aborted'
status = '(aborted)'
} else if (xhr.canceled) {
indicator = 'aborted'
status = '(canceled)'
} else if (xhr.status > 0) {
status = xhr.status
} else {
indicator = 'pending'
status = '---'
}
if (!indicator) {
indicator = /^2/.test(status) ? 'successful' : 'bad'
}
return {
indicator,
message: `${xhr.method} ${status} ${stripOrigin(xhr.url)}`,
}
},
})
return log.snapshot('request')
},
onLoad: (xhr) => {
setResponse(state, xhr)
const log = logs[xhr.id]
if (log) {
return log.snapshot('response').end()
}
},
onNetworkError (xhr) {
const err = $errUtils.cypressErrByPath('xhr.network_error')
const log = logs[xhr.id]
if (log) {
return log.snapshot('failed').error(err)
}
},
onFixtureError (xhr, err) {
err = $errUtils.cypressErr({ message: err })
return this.onError(xhr, err)
},
onError (xhr, err) {
err.onFail = function () {}
const log = logs[xhr.id]
if (log) {
log.snapshot('error').error(err)
}
// re-throw the error since this came from AUT code, and needs to
// cause an 'uncaught:exception' event. This error will be caught in
// top.onerror with stack as 5th argument.
throw err
},
onXhrAbort: (xhr, stack) => {
setResponse(state, xhr)
const err = $errUtils.errByPath('xhr.aborted')
err.name = 'AbortError'
err.stack = $stackUtils.replacedStack(err, stack)
const log = logs[xhr.id]
if (log) {
return log.snapshot('aborted').error(err)
}
},
onXhrCancel (xhr) {
setResponse(state, xhr)
const log = logs[xhr.id]
if (log) {
return log.snapshot('canceled').set({
ended: true,
state: 'failed',
})
}
},
onAnyAbort: (route, xhr) => {
if (route && _.isFunction(route.onAbort)) {
return route.onAbort.call(cy, xhr)
}
},
onAnyRequest: (route, xhr) => {
if (route && _.isFunction(route.onRequest)) {
return route.onRequest.call(cy, xhr)
}
},
onAnyResponse: (route, xhr) => {
if (route && _.isFunction(route.onResponse)) {
return route.onResponse.call(cy, xhr)
}
},
})
const win = state('window')
server.bindTo(win)
state('server', server)
return server
}
const defaults = {
method: undefined,
status: undefined,
delay: undefined,
headers: undefined, // response headers
response: undefined,
autoRespond: undefined,
waitOnResponses: undefined,
onAbort: undefined,
onRequest: undefined, // need to rebind these to 'cy' context
onResponse: undefined,
}
module.exports = (Commands, Cypress, cy, state, config) => {
reset()
// if our page is going away due to
// a form submit / anchor click then
// we need to cancel all pending
// XHR's so the command log displays
// correctly
Cypress.on('window:unload', cancelPendingXhrs)
Cypress.on('test:before:run', () => {
// reset the existing server
reset()
// create the server before each test run
// its possible for this to fail if the
// last test we ran ended with an invalid
// window such as if the last test ended
// with a cross origin window
try {
server = startXhrServer(cy, state, config)
} catch (err) {
// in this case, just don't bind to the server
server = null
}
return null
})
Cypress.on('window:before:load', (contentWindow) => {
if (server) {
// dynamically bind the server to whatever is currently running
return server.bindTo(contentWindow)
}
server = startXhrServer(cy, state, config)
})
return Commands.addAll({
server (options) {
$errUtils.warnByPath('server.deprecated')
let userOptions = options
if (arguments.length === 0) {
userOptions = {}
}
if (!_.isObject(userOptions)) {
$errUtils.throwErrByPath('server.invalid_argument')
}
options = _.defaults({}, userOptions, {
enable: true, // set enable to false to turn off stubbing
})
// if we disable the server later make sure
// we cannot add cy.routes to it
state('serverIsStubbed', options.enable)
return getXhrServer(state).set(options)
},
route (...args) {
$errUtils.warnByPath('route.deprecated')
// TODO:
// if we return a function which returns a promise
// then we should be handling potential timeout issues
// just like cy.then does
// method / url / response / options
// url / response / options
// options
// by default assume we have a specified
// response from the user
let hasResponse = true
if (!state('serverIsStubbed')) {
$errUtils.throwErrByPath('route.failed_prerequisites')
}
// get the default options currently set
// on our server
let o = getXhrServer(state).getOptions()
let options = o
// enable the entire routing definition to be a function
const parseArgs = (...args) => {
if (_.isObject(args[0]) && !_.isRegExp(args[0])) {
// we dont have a specified response
if (!_.has(args[0], 'response')) {
hasResponse = false
}
options = (o = _.extend({}, options, args[0]))
} else if (args.length === 0) {
$errUtils.throwErrByPath('route.invalid_arguments')
} else if (args.length === 1) {
o.url = args[0]
hasResponse = false
} else if (args.length === 2) {
// if our url actually matches an http method
// then we know the user doesn't want to stub this route
if (_.isString(args[0]) && $utils.isValidHttpMethod(args[0])) {
o.method = args[0]
o.url = args[1]
hasResponse = false
} else {
o.url = args[0]
o.response = args[1]
}
} else if (args.length === 3) {
if ($utils.isValidHttpMethod(args[0]) || isUrlLikeArgs(args[1], args[2])) {
o.method = args[0]
o.url = args[1]
o.response = args[2]
} else {
o.url = args[0]
o.response = args[1]
_.extend(o, args[2])
}
} else if (args.length === 4) {
o.method = args[0]
o.url = args[1]
o.response = args[2]
_.extend(o, args[3])
}
if (_.isString(o.method)) {
o.method = o.method.toUpperCase()
}
_.defaults(options, defaults)
if (!options.url) {
$errUtils.throwErrByPath('route.url_missing')
}
if (!_.isString(options.url) && !_.isRegExp(options.url)) {
$errUtils.throwErrByPath('route.url_invalid')
}
if (!$utils.isValidHttpMethod(options.method)) {
$errUtils.throwErrByPath('route.method_invalid', {
args: { method: o.method },
})
}
if (hasResponse && (options.response == null)) {
$errUtils.throwErrByPath('route.response_invalid')
}
// convert to wildcard regex
if (options.url === '*') {
options.originalUrl = '*'
options.url = /.*/
}
// look ahead to see if this
// command (route) has an alias?
const alias = cy.getNextAlias()
if (alias) {
options.alias = alias
}
if (_.isFunction(o.response)) {
const getResponse = () => {
return o.response.call(state('runnable').ctx, options)
}
// allow route to return a promise
return Promise.try(getResponse)
.then((resp) => {
options.response = resp
return route()
})
}
return route()
}
const route = () => {
const aliasObj = cy.getAlias(o.response, 'route')
// if our response is a string and
// a reference to an alias
if (_.isString(o.response) && aliasObj) {
// reset the route's response to be the
// aliases subject
options.response = aliasObj.subject
}
const url = getUrl(options)
const urlString = url.toString()
const decodedUrl = tryDecodeUri(urlString)
// https://github.com/cypress-io/cypress/issues/2372
if (decodedUrl && urlString !== decodedUrl) {
$errUtils.warnByPath('route.url_percentencoding_warning', { args: { decodedUrl } })
}
options.log = Cypress.log({
name: 'route',
instrument: 'route',
method: options.method,
url: getUrl(options),
status: options.status,
response: options.response,
alias: options.alias,
isStubbed: (options.response != null),
numResponses: 0,
consoleProps () {
return {
Method: options.method,
URL: url,
Status: options.status,
Response: options.response,
Alias: options.alias,
}
},
})
return getXhrServer(state).route(options)
}
if (_.isFunction(args[0])) {
const getArgs = () => {
return args[0].call(state('runnable').ctx)
}
return Promise.try(getArgs)
.then(parseArgs)
}
return parseArgs(...args)
},
})
}