UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

622 lines (486 loc) 14.6 kB
const _ = require('lodash') const $ = require('jquery') const clone = require('clone') const $Snapshots = require('../cy/snapshots') const $Events = require('./events') const $dom = require('../dom') const $utils = require('./utils') const $errUtils = require('./error_utils') // adds class methods for command, route, and agent logging // including the intermediate $Log interface const groupsOrTableRe = /^(groups|table)$/ const parentOrChildRe = /parent|child/ const SNAPSHOT_PROPS = 'id snapshots $el url coords highlightAttr scrollBy viewportWidth viewportHeight'.split(' ') const DISPLAY_PROPS = 'id alias aliasType callCount displayName end err event functionName hookId instrument isStubbed message method name numElements numResponses referencesAlias renderProps state testId timeout type url visible wallClockStartedAt testCurrentRetry'.split(' ') const BLACKLIST_PROPS = 'snapshots'.split(' ') let delay = null let counter = 0 const { HIGHLIGHT_ATTR } = $Snapshots // mutate attrs by nulling out // object properties const reduceMemory = (attrs) => { return _.each(attrs, (value, key) => { if (_.isObject(value)) { attrs[key] = null } }) } const toSerializedJSON = function (attrs) { const { isDom } = $dom const stringify = function (value, key) { if (BLACKLIST_PROPS.includes(key)) { return null } if (_.isArray(value)) { return _.map(value, stringify) } if (isDom(value)) { return $dom.stringify(value, 'short') } if (!(!_.isFunction(value) || !groupsOrTableRe.test(key))) { return value() } if (_.isFunction(value)) { return value.toString() } if (_.isObject(value)) { // clone to nuke circular references // and blow away anything that throws try { return _.mapValues(clone(value), stringify) } catch (err) { return null } } return value } return _.mapValues(attrs, stringify) } const getDisplayProps = (attrs) => { return _.pick(attrs, DISPLAY_PROPS) } const getConsoleProps = (attrs) => { return attrs.consoleProps } const getSnapshotProps = (attrs) => { return _.pick(attrs, SNAPSHOT_PROPS) } const countLogsByTests = function (tests = {}) { if (_.isEmpty(tests)) { return 0 } return _ .chain(tests) .flatMap((test) => { return [test, test.prevAttempts] }) .flatMap((tests) => { return [].concat(tests.agents, tests.routes, tests.commands) }).compact() .union([{ id: 0 }]) .map('id') .max() .value() } // TODO: fix this const setCounter = (num) => { return counter = num } const setDelay = (val) => { return delay = val != null ? val : 4 } const defaults = function (state, config, obj) { const instrument = obj.instrument != null ? obj.instrument : 'command' // dont set any defaults if this // is an agent or route because we // may not even be inside of a command if (instrument === 'command') { const current = state('current') // we are logging a command instrument by default _.defaults(obj, current != null ? current.pick('name', 'type') : undefined) // force duals to become either parents or childs // normally this would be handled by the command itself // but in cases where the command purposely does not log // then it could still be logged during a failure, which // is why we normalize its type value if (!parentOrChildRe.test(obj.type)) { // does this command have a previously linked command // by chainer id obj.type = (current != null ? current.hasPreviouslyLinkedCommand() : undefined) ? 'child' : 'parent' } _.defaults(obj, { timeout: config('defaultCommandTimeout'), event: false, renderProps () { return {} }, consoleProps () { // if we don't have a current command just bail if (!current) { return {} } const ret = $dom.isElement(current.get('subject')) ? $dom.getElements(current.get('subject')) : current.get('subject') return { Yielded: ret } }, }) // if obj.isCurrent // stringify the obj.message (if it exists) or current.get("args") obj.message = $utils.stringify(obj.message != null ? obj.message : (current != null ? current.get('args') : undefined)) // allow type to by a dynamic function // so it can conditionally return either // parent or child (useful in assertions) if (_.isFunction(obj.type)) { obj.type = obj.type(current, state('subject')) } } const runnable = state('runnable') const getTestAttemptFromRunnable = (runnable) => { if (!runnable) { return } const t = $utils.getTestFromRunnable(runnable) return t._currentRetry || 0 } return _.defaults(obj, { id: (counter += 1), state: 'pending', instrument: 'command', url: state('url'), hookId: state('hookId'), testId: runnable ? runnable.id : undefined, testCurrentRetry: getTestAttemptFromRunnable(state('runnable')), viewportWidth: state('viewportWidth'), viewportHeight: state('viewportHeight'), referencesAlias: undefined, alias: undefined, aliasType: undefined, message: undefined, timeout: undefined, wallClockStartedAt: new Date().toJSON(), renderProps () { return {} }, consoleProps () { return {} }, }) } const Log = function (cy, state, config, obj) { obj = defaults(state, config, obj) // private attributes of each log const attributes = {} return { get (attr) { if (attr) { return attributes[attr] } return attributes }, unset (key) { return this.set(key, undefined) }, invoke (key) { const invoke = () => { // ensure this is a callable function // and set its default to empty object literal const fn = this.get(key) if (_.isFunction(fn)) { return fn() } return fn } return invoke() || {} }, toJSON () { return _ .chain(attributes) .omit('error') .omitBy(_.isFunction) .extend({ err: $errUtils.wrapErr(this.get('error')), consoleProps: this.invoke('consoleProps'), renderProps: this.invoke('renderProps'), }) .value() }, set (key, val) { if (_.isString(key)) { obj = {} obj[key] = val } else { obj = key } if ('url' in obj) { // always stringify the url property obj.url = (obj.url != null ? obj.url : '').toString() } // convert onConsole to consoleProps // for backwards compatibility if (obj.onConsole) { obj.consoleProps = obj.onConsole } // if we have an alias automatically // figure out what type of alias it is if (obj.alias) { _.defaults(obj, { aliasType: obj.$el ? 'dom' : 'primitive' }) } // dont ever allow existing id's to be mutated if (attributes.id) { delete obj.id } _.extend(attributes, obj) // if we have an consoleProps function // then re-wrap it if (obj && _.isFunction(obj.consoleProps)) { this.wrapConsoleProps() } if (obj && obj.$el) { this.setElAttrs() } this.fireChangeEvent() return this }, pick (...args) { return _.pick(attributes, args) }, publicInterface () { return { get: _.bind(this.get, this), on: _.bind(this.on, this), off: _.bind(this.off, this), pick: _.bind(this.pick, this), attributes, } }, snapshot (name, options = {}) { // bail early and don't snapshot if we're in headless mode // or we're not storing tests if (!config('isInteractive') || (config('numTestsKeptInMemory') === 0)) { return this } _.defaults(options, { at: null, next: null, }) const snapshot = cy.createSnapshot(name, this.get('$el')) const snapshots = this.get('snapshots') || [] // insert at index 'at' or whatever is the next position snapshots[options.at || snapshots.length] = snapshot this.set('snapshots', snapshots) if (options.next) { const fn = this.snapshot this.snapshot = function () { // restore the fn this.snapshot = fn // call orig fn with next as name return fn.call(this, options.next) } } return this }, error (err) { this.set({ ended: true, error: err, state: 'failed', }) return this }, end () { // dont set back to passed // if we've already ended if (this.get('ended')) { return } this.set({ ended: true, state: 'passed', }) return this }, getError (err) { return err.stack || err.message }, setElAttrs () { const $el = this.get('$el') if (!$el) { return } if (_.isElement($el)) { // wrap the element in jquery // if its just a plain element return this.set('$el', $($el), { silent: true }) } // if we've passed something like // <window> or <document> here or // a primitive then unset $el if (!$dom.isJquery($el)) { return this.unset('$el') } // make sure all $el elements are visible! obj = { highlightAttr: HIGHLIGHT_ATTR, numElements: $el.length, visible: $el.length === $el.filter(':visible').length, } return this.set(obj, { silent: true }) }, merge (log) { // merges another logs attributes into // ours by also removing / adding any properties // on the original // 1. calculate which properties to unset const unsets = _.chain(attributes).keys().without(..._.keys(log.get())).value() _.each(unsets, (unset) => { return this.unset(unset) }) // 2. merge in any other properties return this.set(log.get()) }, _shouldAutoEnd () { // must be autoEnd // and not already ended // and not an event // and a command return (this.get('autoEnd') !== false) && (this.get('ended') !== true) && (this.get('event') === false) && (this.get('instrument') === 'command') }, finish () { // end our command since our subject // has been resolved at this point // unless its already been 'ended' // or has been specifically told not to auto resolve if (this._shouldAutoEnd()) { return this.snapshot().end() } }, wrapConsoleProps () { const _this = this const { consoleProps } = attributes attributes.consoleProps = function (...args) { const key = _this.get('event') ? 'Event' : 'Command' const consoleObj = {} consoleObj[key] = _this.get('name') // merge in the other properties from consoleProps _.extend(consoleObj, consoleProps.apply(this, args)) // TODO: right here we need to automatically // merge in "Yielded + Element" if there is an $el // and finally add error if one exists if (_this.get('error')) { _.defaults(consoleObj, { Error: _this.getError(_this.get('error')), }) } // add note if no snapshot exists on command instruments if ((_this.get('instrument') === 'command') && !_this.get('snapshots')) { consoleObj.Snapshot = 'The snapshot is missing. Displaying current state of the DOM.' } else { delete consoleObj.Snapshot } return consoleObj } }, } } const create = function (Cypress, cy, state, config) { counter = 0 const logs = {} // give us the ability to change the delay for firing // the change event, or default it to 4 if (delay == null) { delay = setDelay(config('logAttrsDelay')) } const trigger = function (log, event) { // bail if we never fired our initial log event if (!log._hasInitiallyLogged) { return } // bail if we've reset the logs due to a Cypress.abort if (!logs[log.get('id')]) { return } const attrs = log.toJSON() // only trigger this event if our last stored // emitted attrs do not match the current toJSON if (!_.isEqual(log._emittedAttrs, attrs)) { log._emittedAttrs = attrs log.emit(event, attrs) return Cypress.action(event, attrs, log) } } const triggerLog = function (log) { log._hasInitiallyLogged = true return trigger(log, 'command:log:added') } const addToLogs = function (log) { const id = log.get('id') logs[id] = true } const logFn = function (options = {}) { if (!_.isObject(options)) { $errUtils.throwErrByPath('log.invalid_argument', { args: { arg: options } }) } const log = Log(cy, state, config, options) // add event emitter interface $Events.extend(log) const triggerStateChanged = () => { return trigger(log, 'command:log:changed') } // only fire the log:state:changed event // as fast as every 4ms log.fireChangeEvent = _.debounce(triggerStateChanged, 4) log.set(options) // if snapshot was passed // in, go ahead and snapshot if (log.get('snapshot')) { log.snapshot() } // if end was passed in // go ahead and end if (log.get('end')) { log.end({ silent: true }) } if (log.get('error')) { log.error(log.get('error'), { silent: true }) } log.wrapConsoleProps() const onBeforeLog = state('onBeforeLog') // dont trigger log if this function // explicitly returns false if (_.isFunction(onBeforeLog)) { if (onBeforeLog.call(cy, log) === false) { return } } // set the log on the command const current = state('current') if (current) { current.log(log) } addToLogs(log) triggerLog(log) // if not current state then the log is being run // with no command reference, so just end the log if (!current) { log.end({ silent: true }) } return log } logFn._logs = logs return logFn } module.exports = { reduceMemory, toSerializedJSON, getDisplayProps, getConsoleProps, getSnapshotProps, countLogsByTests, setCounter, create, }