UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

1,072 lines (851 loc) 29 kB
/* global cy, Cypress */ const _ = require('lodash') const whatIsCircular = require('@cypress/what-is-circular') const UrlParse = require('url-parse') const Promise = require('bluebird') const $utils = require('../../cypress/utils') const $errUtils = require('../../cypress/error_utils') const $Log = require('../../cypress/log') const $Location = require('../../cypress/location') const debug = require('debug')('cypress:driver:navigation') let id = null let previousDomainVisited = null let hasVisitedAboutBlank = null let currentlyVisitingAboutBlank = null let knownCommandCausedInstability = null const REQUEST_URL_OPTS = 'auth failOnStatusCode retryOnNetworkFailure retryOnStatusCodeFailure method body headers' .split(' ') const VISIT_OPTS = 'url log onBeforeLoad onLoad timeout requestTimeout' .split(' ') .concat(REQUEST_URL_OPTS) const reset = (test = {}) => { knownCommandCausedInstability = false // continuously reset this // before each test run! previousDomainVisited = false // make sure we reset that we haven't // visited about blank again hasVisitedAboutBlank = false currentlyVisitingAboutBlank = false id = test.id } const VALID_VISIT_METHODS = ['GET', 'POST'] const isValidVisitMethod = (method) => { return _.includes(VALID_VISIT_METHODS, method) } const timedOutWaitingForPageLoad = (ms, log) => { debug('timedOutWaitingForPageLoad') $errUtils.throwErrByPath('navigation.timed_out', { args: { configFile: Cypress.config('configFile'), ms, }, onFail: log, }) } const bothUrlsMatchAndRemoteHasHash = (current, remote) => { // the remote has a hash // or the last char of href // is a hash return (remote.hash || remote.href.slice(-1) === '#') && // both must have the same origin current.origin === remote.origin && // both must have the same pathname current.pathname === remote.pathname && // both must have the same query params current.search === remote.search } const cannotVisitDifferentOrigin = (origin, previousUrlVisited, remoteUrl, existingUrl, log) => { const differences = [] if (remoteUrl.protocol !== existingUrl.protocol) { differences.push('protocol') } if (remoteUrl.port !== existingUrl.port) { differences.push('port') } if (remoteUrl.superDomain !== existingUrl.superDomain) { differences.push('superdomain') } const errOpts = { onFail: log, args: { differences: differences.join(', '), previousUrl: previousUrlVisited, attemptedUrl: origin, }, } $errUtils.throwErrByPath('visit.cannot_visit_different_origin', errOpts) } const specifyFileByRelativePath = (url, log) => { $errUtils.throwErrByPath('visit.specify_file_by_relative_path', { onFail: log, args: { attemptedUrl: url, }, }) } const aboutBlank = (cy, win) => { return new Promise((resolve) => { cy.once('window:load', resolve) return $utils.locHref('about:blank', win) }) } const navigationChanged = (Cypress, cy, state, source, arg) => { // get the current url of our remote application const url = cy.getRemoteLocation('href') debug('navigation changed:', url) // dont trigger for empty url's or about:blank if (_.isEmpty(url) || (url === 'about:blank')) { return } // start storing the history entries const urls = state('urls') || [] const previousUrl = _.last(urls) // ensure our new url doesnt match whatever // the previous was. this prevents logging // additionally when the url didnt actually change if (url === previousUrl) { return } // else notify the world and log this event Cypress.action('cy:url:changed', url) urls.push(url) state('urls', urls) state('url', url) // don't output a command log for 'load' or 'before:load' events // return if source in command if (knownCommandCausedInstability) { return } // ensure our new url doesnt match whatever // the previous was. this prevents logging // additionally when the url didnt actually change Cypress.log({ name: 'new url', message: url, event: true, type: 'parent', end: true, snapshot: true, consoleProps () { const obj = { 'New Url': url, } if (source) { obj['Url Updated By'] = source } if (arg) { obj.Args = arg } return obj }, }) } const formSubmitted = (Cypress, e) => { Cypress.log({ type: 'parent', name: 'form sub', message: '--submitting form--', event: true, end: true, snapshot: true, consoleProps () { return { 'Originated From': e.target, 'Args': e, } }, }) } const pageLoading = (bool, Cypress, state) => { if (state('pageLoading') === bool) { return } state('pageLoading', bool) Cypress.action('app:page:loading', bool) } const stabilityChanged = (Cypress, state, config, stable) => { debug('stabilityChanged:', stable) if (currentlyVisitingAboutBlank) { if (stable === false) { // if we're currently visiting about blank // and becoming unstable for the first time // notifiy that we're page loading pageLoading(true, Cypress, state) return } // else wait until after we finish visiting // about blank return } // let the world know that the app is page:loading pageLoading(!stable, Cypress, state) // if we aren't becoming unstable // then just return now if (stable !== false) { return } // if we purposefully just caused the page to load // (and thus instability) don't log this out if (knownCommandCausedInstability) { return } // bail if we dont have a runnable // because beforeunload can happen at any time // we may no longer be testing and thus dont // want to fire a new loading event // TODO // this may change in the future since we want // to add debuggability in the chrome console // which at that point we may keep runnable around if (!state('runnable')) { return } const options = {} _.defaults(options, { timeout: config('pageLoadTimeout'), }) options._log = Cypress.log({ type: 'parent', name: 'page load', message: '--waiting for new page to load--', event: true, timeout: options.timeout, consoleProps () { return { Note: 'This event initially fires when your application fires its \'beforeunload\' event and completes when your application fires its \'load\' event after the next page loads.', } }, }) cy.clearTimeout('page load') const onPageLoadErr = (err) => { state('onPageLoadErr', null) const { originPolicy } = $Location.create(window.location.href) try { $errUtils.throwErrByPath('navigation.cross_origin', { onFail: options._log, args: { configFile: Cypress.config('configFile'), message: err.message, originPolicy, }, }) } catch (error) { err = error return err } } state('onPageLoadErr', onPageLoadErr) const loading = () => { debug('waiting for window:load') return new Promise((resolve) => { return cy.once('window:load', () => { cy.state('onPageLoadErr', null) options._log.set('message', '--page loaded--').snapshot().end() return resolve() }) }) } const reject = (err) => { const r = state('reject') if (r) { return r(err) } } return loading() .timeout(options.timeout, 'page load') .catch(Promise.TimeoutError, () => { // clean this up cy.state('onPageLoadErr', null) try { return timedOutWaitingForPageLoad(options.timeout, options._log) } catch (err) { return reject(err) } }) } // there are really two timeout values - pageLoadTimeout // and the underlying responseTimeout. for the purposes // of resolving resolving the url, we only care about // responseTimeout - since pageLoadTimeout is a driver // and browser concern. therefore we normalize the options // object and send 'responseTimeout' as options.timeout // for the backend. const normalizeTimeoutOptions = (options) => { return _ .chain(options) .pick(REQUEST_URL_OPTS) .extend({ timeout: options.responseTimeout }) .value() } module.exports = (Commands, Cypress, cy, state, config) => { reset() Cypress.on('test:before:run:async', () => { // reset any state on the backend // TODO: this is a bug in e2e it needs to be returned return Cypress.backend('reset:server:state') }) Cypress.on('test:before:run', reset) Cypress.on('stability:changed', (bool, event) => { // only send up page loading events when we're // not stable! stabilityChanged(Cypress, state, config, bool, event) }) Cypress.on('navigation:changed', (source, arg) => { navigationChanged(Cypress, cy, state, source, arg) }) Cypress.on('form:submitted', (e) => { formSubmitted(Cypress, e) }) const visitFailedByErr = (err, url, fn) => { err.url = url Cypress.action('cy:visit:failed', err) return fn() } const requestUrl = (url, options = {}) => { return Cypress.backend( 'resolve:url', url, normalizeTimeoutOptions(options), ) .then((resp = {}) => { if (!resp.isOkStatusCode) { // if we didn't even get an OK response // then immediately die const err = new Error err.gotResponse = true _.extend(err, resp) throw err } if (!resp.isHtml) { // throw invalid contentType error const err = new Error err.invalidContentType = true _.extend(err, resp) throw err } return resp }) } Cypress.on('window:before:load', (contentWindow) => { // TODO: just use a closure here const current = state('current') if (!current) { return } const runnable = state('runnable') if (!runnable) { return } // if a user-loaded script redefines document.querySelectorAll and // numTestsKeptInMemory is 0 (no snapshotting), jQuery thinks // that document.querySelectorAll is not available (it tests to see that // it's the native definition for some reason) and doesn't use it, // which can fail with a weird error if querying shadow dom. // this ensures that jQuery determines support for document.querySelectorAll // before user scripts are executed. // (when snapshotting is enabled, it can achieve the same thing if an XHR // causes it to snapshot before the user script is executed, but that's // not guaranteed to happen.) // https://github.com/cypress-io/cypress/issues/7676 // this shouldn't error, but we wrap it to ignore potential errors // out of an abundance of caution try { cy.$$('body', contentWindow.document) } catch (e) {} // eslint-disable-line no-empty const options = _.last(current.get('args')) return options?.onBeforeLoad?.call(runnable.ctx, contentWindow) }) Commands.addAll({ reload (...args) { let forceReload let userOptions const throwArgsErr = () => { $errUtils.throwErrByPath('reload.invalid_arguments') } switch (args.length) { case 0: forceReload = false userOptions = {} break case 1: if (_.isObject(args[0])) { userOptions = args[0] } else { forceReload = args[0] } break case 2: forceReload = args[0] userOptions = args[1] break default: throwArgsErr() } // clear the current timeout cy.clearTimeout('reload') let cleanup = null const options = _.defaults({}, userOptions, { log: true, timeout: config('pageLoadTimeout'), }) const reload = () => { return new Promise((resolve) => { forceReload = forceReload || false userOptions = userOptions || {} if (!_.isObject(userOptions)) { throwArgsErr() } if (!_.isBoolean(forceReload)) { throwArgsErr() } if (options.log) { options._log = Cypress.log({ timeout: options.timeout }) options._log.snapshot('before', { next: 'after' }) } cleanup = () => { knownCommandCausedInstability = false return cy.removeListener('window:load', resolve) } knownCommandCausedInstability = true cy.once('window:load', resolve) return $utils.locReload(forceReload, state('window')) }) } return reload() .timeout(options.timeout, 'reload') .catch(Promise.TimeoutError, () => { return timedOutWaitingForPageLoad(options.timeout, options._log) }) .finally(() => { if (typeof cleanup === 'function') { cleanup() } return null }) }, go (numberOrString, options = {}) { const userOptions = options options = _.defaults({}, userOptions, { log: true, timeout: config('pageLoadTimeout'), }) if (options.log) { options._log = Cypress.log({ timeout: options.timeout }) } const win = state('window') const goNumber = (num) => { if (num === 0) { $errUtils.throwErrByPath('go.invalid_number', { onFail: options._log }) } let cleanup = null if (options._log) { options._log.snapshot('before', { next: 'after' }) } const go = () => { return Promise.try(() => { let didUnload = false const beforeUnload = () => { didUnload = true } // clear the current timeout cy.clearTimeout() cy.once('window:before:unload', beforeUnload) const didLoad = new Promise((resolve) => { cleanup = function () { cy.removeListener('window:load', resolve) return cy.removeListener('window:before:unload', beforeUnload) } return cy.once('window:load', resolve) }) knownCommandCausedInstability = true win.history.go(num) // need to set the attributes of 'go' // consoleProps here with win // make sure we resolve our go function // with the remove window (just like cy.visit) const retWin = () => state('window') return Promise .delay(100) .then(() => { knownCommandCausedInstability = false // if we've didUnload then we know we're // doing a full page refresh and we need // to wait until if (didUnload) { return didLoad.then(retWin) } return retWin() }) }) } return go() .timeout(options.timeout, 'go') .catch(Promise.TimeoutError, () => { return timedOutWaitingForPageLoad(options.timeout, options._log) }).finally(() => { if (typeof cleanup === 'function') { cleanup() } return null }) } const goString = (str) => { switch (str) { case 'forward': return goNumber(1) case 'back': return goNumber(-1) default: $errUtils.throwErrByPath('go.invalid_direction', { onFail: options._log, args: { str }, }) } } if (_.isFinite(numberOrString)) { return goNumber(numberOrString) } if (_.isString(numberOrString)) { return goString(numberOrString) } $errUtils.throwErrByPath('go.invalid_argument', { onFail: options._log }) }, visit (url, options = {}) { if (options.url && url) { $errUtils.throwErrByPath('visit.no_duplicate_url', { args: { optionsUrl: options.url, url } }) } let userOptions = options if (_.isObject(url) && _.isEqual(userOptions, {})) { // options specified as only argument userOptions = url url = userOptions.url } if (!_.isString(url)) { $errUtils.throwErrByPath('visit.invalid_1st_arg') } const consoleProps = {} if (!_.isEmpty(userOptions)) { consoleProps['Options'] = _.pick(userOptions, VISIT_OPTS) } options = _.defaults({}, userOptions, { auth: null, failOnStatusCode: true, retryOnNetworkFailure: true, retryOnStatusCodeFailure: false, method: 'GET', body: null, headers: {}, log: true, responseTimeout: config('responseTimeout'), timeout: config('pageLoadTimeout'), onBeforeLoad () {}, onLoad () {}, }) if (!_.isUndefined(options.qs) && !_.isObject(options.qs)) { $errUtils.throwErrByPath('visit.invalid_qs', { args: { qs: String(options.qs) } }) } if (options.retryOnStatusCodeFailure && !options.failOnStatusCode) { $errUtils.throwErrByPath('visit.status_code_flags_invalid') } if (!isValidVisitMethod(options.method)) { $errUtils.throwErrByPath('visit.invalid_method', { args: { method: options.method } }) } if (!_.isObject(options.headers)) { $errUtils.throwErrByPath('visit.invalid_headers') } const path = whatIsCircular(options.body) if (_.isObject(options.body) && path) { $errUtils.throwErrByPath('visit.body_circular', { args: { path } }) } if (options.log) { let message = url if (options.method !== 'GET') { message = `${options.method} ${message}` } options._log = Cypress.log({ message, timeout: options.timeout, consoleProps () { return consoleProps }, }) } url = $Location.normalize(url) const baseUrl = config('baseUrl') if (baseUrl) { url = $Location.qualifyWithBaseUrl(baseUrl, url) } const qs = options.qs if (qs) { url = $Location.mergeUrlWithParams(url, qs) } let cleanup = null // clear the current timeout cy.clearTimeout('visit') let win = state('window') const $autIframe = state('$autIframe') const runnable = state('runnable') const changeIframeSrc = (url, event) => { // when the remote iframe's load event fires // callback fn return new Promise((resolve) => { // if we're listening for hashchange // events then change the strategy // to listen to this event emitting // from the window and not cy // see issue 652 for why. // the hashchange events are firing too // fast for us. They even resolve asynchronously // before other application's hashchange events // have even fired. if (event === 'hashchange') { win.addEventListener('hashchange', resolve) } else { cy.once(event, resolve) } cleanup = () => { if (event === 'hashchange') { win.removeEventListener('hashchange', resolve) } else { cy.removeListener(event, resolve) } knownCommandCausedInstability = false } knownCommandCausedInstability = true return $utils.iframeSrc($autIframe, url) }) } const onLoad = ({ runOnLoadCallback, totalTime }) => { // reset window on load win = state('window') // the onLoad callback should only be skipped if specified if (runOnLoadCallback !== false) { try { options.onLoad?.call(runnable.ctx, win) } catch (err) { // mark these as onLoad errors, so they're treated differently // than Node.js errors when caught below err.isOnLoadError = true throw err } } if (options._log) { options._log.set({ url, totalTime, }) } return Promise.resolve(win) } const go = () => { // hold onto our existing url const existing = $utils.locExisting() // TODO: $Location.resolve(existing.origin, url) if ($Location.isLocalFileUrl(url)) { return specifyFileByRelativePath(url, options._log) } let remoteUrl // in the case we are visiting a relative url // then prepend the existing origin to it // so we get the right remote url if (!$Location.isFullyQualifiedUrl(url)) { remoteUrl = $Location.fullyQualifyUrl(url) } let remote = $Location.create(remoteUrl || url) // reset auth options if we have them const a = remote.authObj if (a) { options.auth = a } // store the existing hash now since // we'll need to apply it later const existingHash = remote.hash || '' const existingAuth = remote.auth || '' if (previousDomainVisited && (remote.originPolicy !== existing.originPolicy)) { // if we've already visited a new superDomain // then die else we'd be in a terrible endless loop // we also need to disable retries to prevent the endless loop $utils.getTestFromRunnable(state('runnable'))._retries = 0 return cannotVisitDifferentOrigin(remote.origin, previousDomainVisited, remote, existing, options._log) } const current = $Location.create(win.location.href) // if all that is changing is the hash then we know // the browser won't actually make a new http request // for this, and so we need to resolve onLoad immediately // and bypass the actual visit resolution stuff if (bothUrlsMatchAndRemoteHasHash(current, remote)) { // https://github.com/cypress-io/cypress/issues/1311 if (current.hash === remote.hash) { consoleProps['Note'] = 'Because this visit was to the same hash, the page did not reload and the onBeforeLoad and onLoad callbacks did not fire.' return onLoad({ runOnLoadCallback: false }) } return changeIframeSrc(remote.href, 'hashchange') .then(onLoad) } if (existingHash) { // strip out the existing hash if we have one // before telling our backend to resolve this url url = url.replace(existingHash, '') } if (existingAuth) { // strip out the existing url if we have one url = url.replace(`${existingAuth}@`, '') } return requestUrl(url, options) .then((resp = {}) => { let { url, originalUrl, cookies, redirects, filePath } = resp // reapply the existing hash url += existingHash originalUrl += existingHash if (filePath) { consoleProps['File Served'] = filePath } else { if (url !== originalUrl) { consoleProps['Original Url'] = originalUrl } } if (options.log) { let message = options._log.get('message') if (redirects && redirects.length) { message = [message].concat(redirects).join(' -> ') } options._log.set({ message }) } consoleProps['Resolved Url'] = url consoleProps['Redirects'] = redirects consoleProps['Cookies Set'] = cookies remote = $Location.create(url) // if the origin currently matches // then go ahead and change the iframe's src // and we're good to go // if origin is existing.origin if (remote.originPolicy === existing.originPolicy) { previousDomainVisited = remote.origin url = $Location.fullyQualifyUrl(url) return changeIframeSrc(url, 'window:load') .then(() => { return onLoad(resp) }) } // if we've already visited a new origin // then die else we'd be in a terrible endless loop if (previousDomainVisited) { return cannotVisitDifferentOrigin(remote.origin, previousDomainVisited, remote, existing, options._log) } // tell our backend we're changing domains // TODO: add in other things we want to preserve // state for like scrollTop let s = { currentId: id, tests: Cypress.runner.getTestsState(), startTime: Cypress.runner.getStartTime(), emissions: Cypress.runner.getEmissions(), } s.passed = Cypress.runner.countByTestState(s.tests, 'passed') s.failed = Cypress.runner.countByTestState(s.tests, 'failed') s.pending = Cypress.runner.countByTestState(s.tests, 'pending') s.numLogs = $Log.countLogsByTests(s.tests) return Cypress.action('cy:collect:run:state') .then((a = []) => { // merge all the states together holla' s = _.reduce(a, (memo, obj) => { return _.extend(memo, obj) }, s) return Cypress.backend('preserve:run:state', s) }) .then(() => { // and now we must change the url to be the new // origin but include the test that we're currently on const newUri = new UrlParse(remote.origin) newUri .set('pathname', existing.pathname) .set('query', existing.search) .set('hash', existing.hash) // replace is broken in electron so switching // to href for now // $utils.locReplace(window, newUri.toString()) $utils.locHref(newUri.toString(), window) // we are returning a Promise which never resolves // because we're changing top to be a brand new URL // and want to block the rest of our commands return Promise.delay(1e9) }) }) .catch((err) => { if (err.gotResponse || err.invalidContentType) { visitFailedByErr(err, err.originalUrl, () => { const args = { url: err.originalUrl, path: err.filePath, status: err.status, statusText: err.statusText, redirects: err.redirects, contentType: err.contentType, } let msg = '' if (err.gotResponse) { const type = err.filePath ? 'file' : 'http' msg = `visit.loading_${type}_failed` } if (err.invalidContentType) { msg = 'visit.loading_invalid_content_type' } $errUtils.throwErrByPath(msg, { onFail: options._log, args, }) }) return } // if it came from the user's onLoad callback, it's not a network // failure, and we should just throw the original error if (err.isOnLoadError) { delete err.isOnLoadError throw err } visitFailedByErr(err, url, () => { $errUtils.throwErrByPath('visit.loading_network_failed', { onFail: options._log, args: { url, error: err, }, errProps: { appendToStack: { title: 'From Node.js Internals', content: err.stack, }, }, }) }) }) } const visit = () => { // if we've visiting for the first time during // a test then we want to first visit about:blank // so that we nuke the previous state. subsequent // visits will not navigate to about:blank so that // our history entries are intact if (!hasVisitedAboutBlank) { hasVisitedAboutBlank = true currentlyVisitingAboutBlank = true return aboutBlank(cy, win) .then(() => { currentlyVisitingAboutBlank = false return go() }) } return go() } return visit() .timeout(options.timeout, 'visit') .catch(Promise.TimeoutError, () => { return timedOutWaitingForPageLoad(options.timeout, options._log) }).finally(() => { if (typeof cleanup === 'function') { cleanup() } return null }) }, }) }