UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

667 lines (522 loc) 17 kB
const _ = require('lodash') const capitalize = require('underscore.string/capitalize') const minimatch = require('minimatch') const $errUtils = require('./error_utils') const $XHR = require('./xml_http_request') const regularResourcesRe = /\.(jsx?|coffee|html|less|s?css|svg)(\?.*)?$/ const needsDashRe = /([a-z][A-Z])/g const props = 'onreadystatechange onload onerror'.split(' ') let restoreFn = null const setHeader = (xhr, key, val, transformer) => { if (val != null) { if (transformer) { val = transformer(val) } key = `X-Cypress-${capitalize(key)}` return xhr.setRequestHeader(key, encodeURI(val)) } } const normalize = (val) => { val = val.replace(needsDashRe, (match) => { return `${match[0]}-${match[1]}` }) return val.toLowerCase() } const nope = () => { return null } const responseTypeIsTextOrEmptyString = (responseType) => { return responseType === '' || responseType === 'text' } // when the browser naturally cancels/aborts // an XHR because the window is unloading // on chrome < 71 const isAbortedThroughUnload = (xhr) => { return xhr.canceled !== true && xhr.readyState === 4 && xhr.status === 0 && // responseText may be undefined on some responseTypes // https://github.com/cypress-io/cypress/issues/3008 // TODO: How do we want to handle other responseTypes? responseTypeIsTextOrEmptyString(xhr.responseType) && xhr.responseText === '' } const warnOnWhitelistRenamed = (obj, type) => { if (obj.whitelist) { return $errUtils.throwErrByPath('server.whitelist_renamed', { args: { type } }) } } const ignore = (xhr) => { const url = new URL(xhr.url) // https://github.com/cypress-io/cypress/issues/7280 // we want to strip the xhr's URL of any hash and query params before // checking the REGEX for matching file extensions url.search = '' url.hash = '' // allow if we're GET + looks like we're fetching regular resources return xhr.method === 'GET' && regularResourcesRe.test(url.href) } const serverDefaults = { xhrUrl: '', method: 'GET', delay: 0, status: 200, headers: null, response: null, enable: true, autoRespond: true, waitOnResponses: Infinity, force404: false, // to force 404's for non-stubbed routes onAnyAbort: undefined, onAnyRequest: undefined, onAnyResponse: undefined, urlMatchingOptions: { matchBase: true }, stripOrigin: _.identity, getUrlOptions: _.identity, ignore, // function whether to allow a request to go out (css/js/html/templates) etc onOpen () {}, onSend () {}, onXhrAbort () {}, onXhrCancel () {}, onError () {}, onLoad () {}, onFixtureError () {}, onNetworkError () {}, } const restore = () => { if (restoreFn) { restoreFn() restoreFn = null } } const getStack = () => { const err = new Error return err.stack.split('\n').slice(3).join('\n') } const get404Route = () => { return { status: 404, response: '', delay: 0, headers: null, is404: true, } } const transformHeaders = (headers) => { // normalize camel-cased headers key headers = _.reduce(headers, (memo, value, key) => { memo[normalize(key)] = value return memo }, {}) return JSON.stringify(headers) } const normalizeStubUrl = (xhrUrl, url) => { if (!xhrUrl) { $errUtils.warnByPath('server.xhrurl_not_set') } // always ensure this is an absolute-relative url // and remove any double slashes xhrUrl = _.compact(xhrUrl.split('/')).join('/') url = _.trimStart(url, '/') return [`/${xhrUrl}`, url].join('/') } const getFullyQualifiedUrl = (contentWindow, url) => { // the href getter will always resolve a full path const a = contentWindow.document.createElement('a') a.href = url return a.href } // override the defaults for all servers const defaults = (obj = {}) => { // merge obj into defaults return _.extend(serverDefaults, obj) } const create = (options = {}) => { options = _.defaults(options, serverDefaults) const xhrs = {} const proxies = {} const routes = [] // always start disabled // so we dont handle stubs let hasEnabledStubs = false const enableStubs = (bool = true) => { return hasEnabledStubs = bool } const server = { options, restore, getStack, get404Route, transformHeaders, normalizeStubUrl, getFullyQualifiedUrl, getOptions () { // clone the options to prevent // accidental mutations return _.clone(options) }, getRoutes () { return routes }, isIgnored (xhr) { return options.ignore(xhr) }, shouldApplyStub (route) { return hasEnabledStubs && route && (route.response != null) }, applyStubProperties (xhr, route) { const responser = _.isObject(route.response) ? JSON.stringify : null // add header properties for the xhr's id // and the testId setHeader(xhr, 'id', xhr.id) // setHeader(xhr, "testId", options.testId) setHeader(xhr, 'status', route.status) setHeader(xhr, 'response', route.response, responser) setHeader(xhr, 'matched', `${route.url}`) setHeader(xhr, 'delay', route.delay) return setHeader(xhr, 'headers', route.headers, transformHeaders) }, route (attrs = {}) { // merge attrs with the server's defaults // so we preserve the state of the attrs // at the time they're created since we // can create another server later // dont mutate the original attrs const route = _.defaults( {}, attrs, _.pick(options, 'delay', 'method', 'status', 'autoRespond', 'waitOnResponses', 'onRequest', 'onResponse'), ) routes.push(route) return route }, getRouteForXhr (xhr) { // return the 404 stub if we dont have any stubs // but we are stubbed - meaning we havent added any routes // but have started the server // and this request shouldnt be allowed if (!routes.length && hasEnabledStubs && options.force404 !== false && !server.isIgnored(xhr)) { return get404Route() } // bail if we've attached no stubs if (!routes.length) { return nope() } // bail if this xhr matches our ignore list if (server.isIgnored(xhr)) { return nope() } // loop in reverse to get // the first matching stub // thats been most recently added for (let i = routes.length - 1; i >= 0; i--) { const route = routes[i] if (server.xhrMatchesRoute(xhr, route)) { return route } } // else if no stub matched // send 404 if we're allowed to if (options.force404) { return get404Route() } // else return null return nope() }, methodsMatch (routeMethod, xhrMethod) { // normalize both methods by uppercasing them return routeMethod.toUpperCase() === xhrMethod.toUpperCase() }, urlsMatch (routePattern, fullyQualifiedUrl) { const match = (str, pattern) => { // be nice to our users and prepend // pattern with "/" if it doesnt have one // and str does if (pattern[0] !== '/' && str[0] === '/') { pattern = `/${pattern}` } return minimatch(str, pattern, options.urlMatchingOptions) } const testRe = (url1, url2) => { return routePattern.test(url1) || routePattern.test(url2) } const testStr = (url1, url2) => { return (routePattern === url1) || (routePattern === url2) || match(url1, routePattern) || match(url2, routePattern) } if (_.isRegExp(routePattern)) { return testRe(fullyQualifiedUrl, options.stripOrigin(fullyQualifiedUrl)) } return testStr(fullyQualifiedUrl, options.stripOrigin(fullyQualifiedUrl)) }, xhrMatchesRoute (xhr, route) { return server.methodsMatch(route.method, xhr.method) && server.urlsMatch(route.url, xhr.url) }, add (xhr, attrs = {}) { const id = _.uniqueId('xhr') _.extend(xhr, attrs) xhr.id = id xhrs[id] = xhr proxies[id] = $XHR.create(xhr) return proxies[id] }, getProxyFor (xhr) { return proxies[xhr.id] }, abortXhr (xhr) { const proxy = server.getProxyFor(xhr) // if the XHR leaks into the next test // after we've reset our internal server // then this may be undefined if (!proxy) { return } // return if we're already aborted which // can happen if the browser already canceled // this xhr but we called abort later if (xhr.aborted) { return } xhr.aborted = true const abortStack = server.getStack() proxy.aborted = true options.onXhrAbort(proxy, abortStack) if (_.isFunction(options.onAnyAbort)) { const route = server.getRouteForXhr(xhr) // call the onAnyAbort function // after we've called options.onSend return options.onAnyAbort(route, proxy) } }, cancelXhr (xhr) { const proxy = server.getProxyFor(xhr) // if the XHR leaks into the next test // after we've reset our internal server // then this may be undefined if (!proxy) { return } xhr.canceled = true proxy.canceled = true options.onXhrCancel(proxy) return xhr }, cancelPendingXhrs () { // cancel any outstanding xhr's // which aren't already complete // or already canceled return _ .chain(xhrs) .reject({ readyState: 4 }) .reject({ canceled: true }) .map(server.cancelXhr) .value() }, set (obj) { warnOnWhitelistRenamed(obj, 'server') // handle enable=true|false if (obj.enable != null) { enableStubs(obj.enable) } return _.extend(options, obj) }, bindTo (contentWindow) { restore() const XHR = contentWindow.XMLHttpRequest const { send, open, abort } = XHR.prototype const srh = XHR.prototype.setRequestHeader restoreFn = () => { // restore the property back on the window return _.each( { send, open, abort, setRequestHeader: srh }, (value, key) => { return XHR.prototype[key] = value }, ) } XHR.prototype.setRequestHeader = function (...args) { // if the XHR leaks into the next test // after we've reset our internal server // then this may be undefined const proxy = server.getProxyFor(this) if (proxy) { proxy._setRequestHeader.apply(proxy, args) } return srh.apply(this, args) } XHR.prototype.abort = function (...args) { // if we already have a readyState of 4 // then do not get the abort stack or // set the aborted property or call onXhrAbort // to test this just use a regular XHR if (this.readyState !== 4) { server.abortXhr(this) } return abort.apply(this, args) } XHR.prototype.open = function (method, url, async = true, username, password) { // get the fully qualified url that normally the browser // would be sending this request to // FQDN: http://www.google.com/responses/users.json // relative: partials/phones-list.html // absolute-relative: /app/partials/phones-list.html const fullyQualifiedUrl = getFullyQualifiedUrl(contentWindow, url) // decode the entire url.display to make // it easier to do assertions const proxy = server.add(this, { method, url: decodeURIComponent(fullyQualifiedUrl), }) // if this XHR matches a stubbed route then shift // its url to the stubbed url and set the request // headers for the response const route = server.getRouteForXhr(this) if (server.shouldApplyStub(route)) { url = server.normalizeStubUrl(options.xhrUrl, fullyQualifiedUrl) } const timeStart = new Date const xhr = this const fns = {} const overrides = {} const bailIfRecursive = (fn) => { let isCalled = false return (...args) => { if (isCalled) { return } isCalled = true try { return fn.apply(window, args) } finally { isCalled = false } } } const onLoadFn = function (...args) { proxy._setDuration(timeStart) proxy._setStatus() proxy._setResponseHeaders() proxy._setResponseBody() let err = proxy._getFixtureError() if (err) { return options.onFixtureError(proxy, err) } // catch synchronous errors caused // by the onload function try { const ol = fns.onload if (_.isFunction(ol)) { ol.apply(xhr, args) } options.onLoad(proxy, route) } catch (error) { err = error options.onError(proxy, err) } if (_.isFunction(options.onAnyResponse)) { return options.onAnyResponse(route, proxy) } } const onErrorFn = function (...args) { // its possible our real onerror handler // throws so we need to catch those errors too try { const oe = fns.onerror if (_.isFunction(oe)) { oe.apply(xhr, args) } return options.onNetworkError(proxy) } catch (err) { return options.onError(proxy, err) } } const onReadyStateFn = function (...args) { // catch synchronous errors caused // by the onreadystatechange function try { const orst = fns.onreadystatechange if (isAbortedThroughUnload(xhr)) { server.abortXhr(xhr) } if (_.isFunction(orst)) { return orst.apply(xhr, args) } } catch (err) { // its failed stop sending the callack xhr.onreadystatechange = null return options.onError(proxy, err) } } // bail if eventhandlers have already been called to prevent // infinite recursion overrides.onload = bailIfRecursive(onLoadFn) overrides.onerror = bailIfRecursive(onErrorFn) overrides.onreadystatechange = bailIfRecursive(onReadyStateFn) props.forEach((prop) => { // if we currently have one of these properties then // back them up! const fn = xhr[prop] if (fn) { fns[prop] = fn } // set the override now xhr[prop] = overrides[prop] // and in the future if this is redefined // then just back it up return Object.defineProperty(xhr, prop, { get () { const bak = fns[prop] if (_.isFunction(bak)) { return (...args) => { return bak.apply(xhr, args) } } return overrides[prop] }, set (fn) { fns[prop] = fn }, configurable: true, }) }) options.onOpen(method, url, async, username, password) // change absolute url's to relative ones // if they match our baseUrl / visited URL return open.call(this, method, url, async, username, password) } XHR.prototype.send = function (requestBody) { // if there is an existing route for this // XHR then add those properties into it // only if route isnt explicitly false // and the server is enabled const route = server.getRouteForXhr(this) if (server.shouldApplyStub(route)) { server.applyStubProperties(this, route) } // capture where this xhr came from const sendStack = server.getStack() // get the proxy xhr const proxy = server.getProxyFor(this) proxy._setRequestBody(requestBody) // log this out now since it's being sent officially // unless its not been ignored if (!server.isIgnored(this)) { options.onSend(proxy, sendStack, route) } if (_.isFunction(options.onAnyRequest)) { // call the onAnyRequest function // after we've called options.onSend options.onAnyRequest(route, proxy) } // eslint-disable-next-line prefer-rest-params return send.apply(this, arguments) } }, } return server } module.exports = { defaults, create, }