diff --git a/packages/driver/src/cy/actionability.coffee b/packages/driver/src/cy/actionability.coffee index 6a195c636a51..b81bc987fa7b 100644 --- a/packages/driver/src/cy/actionability.coffee +++ b/packages/driver/src/cy/actionability.coffee @@ -216,6 +216,7 @@ verify = (cy, $el, options, callbacks) -> visibility: true, notDisabled: true, notCovered: true, + notAnimating: true, notReadonly: false, custom: false } @@ -271,7 +272,7 @@ verify = (cy, $el, options, callbacks) -> ## if force is true OR waitForAnimations is false ## then do not perform these additional ensures... - if (force isnt true) and (options.waitForAnimations isnt false) + if (options.ensure.notAnimating) and (force isnt true) and (options.waitForAnimations isnt false) ## store the coords that were absolute ## from the window or from the viewport for sticky elements ## (see https://github.com/cypress-io/cypress/pull/1478) diff --git a/packages/driver/src/cy/commands/actions/type.js b/packages/driver/src/cy/commands/actions/type.js index dcff797ecef9..31d43409a432 100644 --- a/packages/driver/src/cy/commands/actions/type.js +++ b/packages/driver/src/cy/commands/actions/type.js @@ -1,6 +1,5 @@ const _ = require('lodash') const Promise = require('bluebird') -const moment = require('moment') const $dom = require('../../../dom') const $elements = require('../../../dom/elements') @@ -8,573 +7,525 @@ const $selection = require('../../../dom/selection') const $utils = require('../../../cypress/utils') const $actionability = require('../../actionability') const $Keyboard = require('../../../cy/keyboard') - -const inputEvents = 'textInput input'.split(' ') - -const dateRegex = /^\d{4}-\d{2}-\d{2}$/ -const monthRegex = /^\d{4}-(0\d|1[0-2])$/ -const weekRegex = /^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/ -const timeRegex = /^([0-1]\d|2[0-3]):[0-5]\d(:[0-5]\d)?(\.[0-9]{1,3})?$/ +const debug = require('debug')('cypress:driver:command:type') module.exports = function (Commands, Cypress, cy, state, config) { const { keyboard } = cy.devices - return Commands.addAll({ prevSubject: 'element' }, { - type (subject, chars, options = {}) { - let num; let updateTable - - options = _.clone(options) - // allow the el we're typing into to be - // changed by options -- used by cy.clear() - _.defaults(options, { - $el: subject, - log: true, - verify: true, - force: false, - delay: 10, - release: true, - parseSpecialCharSequences: true, - waitForAnimations: config('waitForAnimations'), - animationDistanceThreshold: config('animationDistanceThreshold'), - }) - - if (options.log) { - // figure out the options which actually change the behavior of clicks - const deltaOptions = $utils.filterOutOptions(options) - - const table = {} - - const getRow = (id, key, which) => { - if (table[id]) { - return table[id] - } - - let obj - - table[id] = (obj = {}) - const modifiers = $Keyboard.modifiersToString(keyboard.getActiveModifiers()) - - if (modifiers) { - obj.modifiers = modifiers - } - - if (key) { - obj.typed = key - if (which) { - obj.which = which - } - } - - return obj + function type (subject, chars, options = {}) { + let updateTable + + options = _.clone(options) + // allow the el we're typing into to be + // changed by options -- used by cy.clear() + _.defaults(options, { + $el: subject, + log: true, + verify: true, + force: false, + delay: 10, + release: true, + parseSpecialCharSequences: true, + waitForAnimations: config('waitForAnimations'), + animationDistanceThreshold: config('animationDistanceThreshold'), + }) + + if (options.log) { + // figure out the options which actually change the behavior of clicks + const deltaOptions = $utils.filterOutOptions(options) + + const table = {} + + const getRow = (id, key, which) => { + if (table[id]) { + return table[id] } - updateTable = function (id, key, column, which, value) { - const row = getRow(id, key, which) + const obj = table[id] = {} + const modifiers = $Keyboard.modifiersToString(keyboard.getActiveModifiers(state)) - row[column] = value || 'preventedDefault' + if (modifiers) { + obj.modifiers = modifiers } - // transform table object into object with zero based index as keys - const getTableData = () => { - return _.reduce(_.values(table), (memo, value, index) => { - memo[index + 1] = value + if (key) { + obj.typed = key - return memo + if (which) { + obj.which = which } - , {}) } - options._log = Cypress.log({ - message: [chars, deltaOptions], - $el: options.$el, - consoleProps () { - return { - 'Typed': chars, - 'Applied To': $dom.getElements(options.$el), - 'Options': deltaOptions, - 'table': { - // mouse events tables will take up slots 1 and 2 if they're present - // this preserves the order of the tables - 3: () => { - return { - name: 'Keyboard Events', - data: getTableData(), - columns: ['typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers'], - } - }, - }, - } - }, - }) - - options._log.snapshot('before', { next: 'after' }) + return obj } - const isBody = options.$el.is('body') - const isTextLike = $dom.isTextLike(options.$el.get(0)) - const isDate = $dom.isType(options.$el, 'date') - const isTime = $dom.isType(options.$el, 'time') - const isMonth = $dom.isType(options.$el, 'month') - const isWeek = $dom.isType(options.$el, 'week') - const hasTabIndex = $dom.isSelector(options.$el, '[tabindex]') - - // TODO: tabindex can't be -1 + updateTable = function (id, key, column, which, value) { + const row = getRow(id, key, which) - const isTypeableButNotAnInput = isBody || (hasTabIndex && !isTextLike) - - if (!isBody && !isTextLike && !hasTabIndex) { - const node = $dom.stringify(options.$el) - - $utils.throwErrByPath('type.not_on_typeable_element', { - onFail: options._log, - args: { node }, - }) + row[column] = value || 'preventedDefault' } - if ((num = options.$el.length) && (num > 1)) { - $utils.throwErrByPath('type.multiple_elements', { - onFail: options._log, - args: { num }, - }) - } - - if (!(_.isString(chars) || _.isFinite(chars))) { - $utils.throwErrByPath('type.wrong_type', { - onFail: options._log, - args: { chars }, - }) - } + // transform table object into object with zero based index as keys + const getTableData = () => { + return _.reduce(_.values(table), (memo, value, index) => { + memo[index + 1] = value - if (chars === '') { - $utils.throwErrByPath('type.empty_string', { onFail: options._log }) + return memo + } + , {}) } - if (!(chars === '{selectall}{del}')) { - if (isDate && ( - !_.isString(chars) || - !dateRegex.test(chars) || - !moment(chars).isValid() - )) { - $utils.throwErrByPath('type.invalid_date', { - onFail: options._log, - args: { chars }, - }) - } + options._log = Cypress.log({ + message: [chars, deltaOptions], + $el: options.$el, + consoleProps () { + return { + 'Typed': chars, + 'Applied To': $dom.getElements(options.$el), + 'Options': deltaOptions, + 'table': { + // mouse events tables will take up slots 1 and 2 if they're present + // this preserves the order of the tables + 3: () => { + return { + name: 'Keyboard Events', + data: getTableData(), + columns: ['typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers'], + } + }, + }, + } + }, + }) - if (isMonth && ( - !_.isString(chars) || - !monthRegex.test(chars) - )) { - $utils.throwErrByPath('type.invalid_month', { - onFail: options._log, - args: { chars }, - }) - } + options._log.snapshot('before', { next: 'after' }) + } - if (isWeek && ( - !_.isString(chars) || - !weekRegex.test(chars) - )) { - $utils.throwErrByPath('type.invalid_week', { - onFail: options._log, - args: { chars }, - }) - } + if (options.$el.length > 1) { + $utils.throwErrByPath('type.multiple_elements', { + onFail: options._log, + args: { num: options.$el.length }, + }) + } - if (isTime && ( - !_.isString(chars) || - !timeRegex.test(chars) - )) { - $utils.throwErrByPath('type.invalid_time', { - onFail: options._log, - args: { chars }, - }) - } - } + if (!(_.isString(chars) || _.isFinite(chars))) { + $utils.throwErrByPath('type.wrong_type', { + onFail: options._log, + args: { chars }, + }) + } - options.chars = `${chars}` + if (_.isString(chars) && _.isEmpty(chars)) { + $utils.throwErrByPath('type.empty_string', { + onFail: options._log, + args: { chars }, + }) + } - const win = state('window') + chars = `${chars}` - const getDefaultButtons = (form) => { - return form.find('input, button').filter((__, el) => { - const $el = $dom.wrap(el) + const win = state('window') - return ($dom.isSelector($el, 'input') && $dom.isType($el, 'submit')) || - ($dom.isSelector($el, 'button') && !$dom.isType($el, 'button') && !$dom.isType($el, 'reset')) - }) - } + const getDefaultButtons = (form) => { + return form.find('input, button').filter((__, el) => { + const $el = $dom.wrap(el) - const type = function () { - const simulateSubmitHandler = function () { - const form = options.$el.parents('form') + return ( + ($dom.isSelector($el, 'input') && $dom.isInputType($el, 'submit')) || + ($dom.isSelector($el, 'button') && !$dom.isInputType($el, 'button') && !$dom.isInputType($el, 'reset')) + ) + }) + } - if (!form.length) { - return - } + const type = function () { + const simulateSubmitHandler = function () { + const form = options.$el.parents('form') - const multipleInputsAllowImplicitSubmissionAndNoSubmitElements = function (form) { - const submits = getDefaultButtons(form) + if (!form.length) { + return + } - // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission - // some types of inputs can submit the form when hitting {enter} - // but only if they are the sole input that allows implicit submission - // and there are no buttons or input[submits] in the form - const implicitSubmissionInputs = form.find('input').filter((__, input) => { - const $input = $dom.wrap(input) + const multipleInputsAllowImplicitSubmissionAndNoSubmitElements = function (form) { + const submits = getDefaultButtons(form) - return $elements.isInputAllowingImplicitFormSubmission($input) - }) + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission + // some types of inputs can submit the form when hitting {enter} + // but only if they are the sole input that allows implicit submission + // and there are no buttons or input[submits] in the form + const implicitSubmissionInputs = form.find('input').filter((__, input) => { + const $input = $dom.wrap(input) - return (implicitSubmissionInputs.length > 1) && (submits.length === 0) - } + return $elements.isInputAllowingImplicitFormSubmission($input) + }) - // throw an error here if there are multiple form parents + return (implicitSubmissionInputs.length > 1) && (submits.length === 0) + } - // bail if we have multiple inputs allowing implicit submission and no submit elements - if (multipleInputsAllowImplicitSubmissionAndNoSubmitElements(form)) { - return - } + // throw an error here if there are multiple form parents - const clickedDefaultButton = function (button) { - // find the 'default button' as per HTML spec and click it natively - // do not issue mousedown / mouseup since this is supposed to be synthentic - if (button.length) { - button.get(0).click() + // bail if we have multiple inputs allowing implicit submission and no submit elements + if (multipleInputsAllowImplicitSubmissionAndNoSubmitElements(form)) { + return + } - return true - } + const clickedDefaultButton = function (button) { + // find the 'default button' as per HTML spec and click it natively + // do not issue mousedown / mouseup since this is supposed to be synthentic + if (button.length) { + button.get(0).click() - return false + return true } - const getDefaultButton = (form) => { - return getDefaultButtons(form).first() - } + return false + } - const defaultButtonisDisabled = (button) => { - return button.prop('disabled') - } + const getDefaultButton = (form) => { + return getDefaultButtons(form).first() + } - const defaultButton = getDefaultButton(form) + const defaultButtonisDisabled = (button) => { + return button.prop('disabled') + } - // bail if the default button is in a 'disabled' state - if (defaultButtonisDisabled(defaultButton)) { - return - } + const defaultButton = getDefaultButton(form) - // issue the click event to the 'default button' of the form - // we need this to be synchronous so not going through our - // own click command - // as of now, at least in Chrome, causing the click event - // on the button will indeed trigger the form submit event - // so we dont need to fire it manually anymore! - if (!clickedDefaultButton(defaultButton)) { - // if we werent able to click the default button - // then synchronously fire the submit event - // currently this is sync but if we use a waterfall - // promise in the submit command it will break again - // consider changing type to a Promise and juggle logging - return cy.now('submit', form, { log: false, $el: form }) - } + // bail if the default button is in a 'disabled' state + if (defaultButtonisDisabled(defaultButton)) { + return } - const dispatchChangeEvent = function (el, id) { - const change = document.createEvent('HTMLEvents') + // issue the click event to the 'default button' of the form + // we need this to be synchronous so not going through our + // own click command + // as of now, at least in Chrome, causing the click event + // on the button will indeed trigger the form submit event + // so we dont need to fire it manually anymore! + if (!clickedDefaultButton(defaultButton)) { + // if we werent able to click the default button + // then synchronously fire the submit event + // currently this is sync but if we use a waterfall + // promise in the submit command it will break again + // consider changing type to a Promise and juggle logging + return cy.now('submit', form, { log: false, $el: form }) + } + } - change.initEvent('change', true, false) + const dispatchChangeEvent = function (el, id) { + const change = document.createEvent('HTMLEvents') - const dispatched = el.dispatchEvent(change) + change.initEvent('change', true, false) - if (id && updateTable) { - return updateTable(id, null, 'change', null, dispatched) - } - } + const dispatched = el.dispatchEvent(change) - const needSingleValueChange = () => { - return $elements.isNeedSingleValueChangeInputElement(options.$el.get(0)) + if (id && updateTable) { + return updateTable(id, null, 'change', null, dispatched) } + } - // see comment in updateValue below - let typed = '' - - const isContentEditable = $elements.isContentEditable(options.$el.get(0)) - const isTextarea = $elements.isTextarea(options.$el.get(0)) - - return keyboard.type({ - $el: options.$el, - chars: options.chars, - delay: options.delay, - release: options.release, - parseSpecialCharSequences: options.parseSpecialCharSequences, - window: win, - - updateValue (el, key) { - // in these cases, the value must only be set after all - // the characters are input because attemping to set - // a partial/invalid value results in the value being - // set to an empty string - if (needSingleValueChange()) { - typed += key - if (typed === options.chars) { - return $elements.setNativeProp(el, 'value', options.chars) - } - } else { - return $selection.replaceSelectionContents(el, key) + const needSingleValueChange = (el) => { + return $elements.isNeedSingleValueChangeInputElement(el) + } + + // see comment in updateValue below + let typed = '' + + const isContentEditable = $elements.isContentEditable(options.$el.get(0)) + const isTextarea = $elements.isTextarea(options.$el.get(0)) + + return keyboard.type({ + $el: options.$el, + chars, + delay: options.delay, + release: options.release, + parseSpecialCharSequences: options.parseSpecialCharSequences, + window: win, + force: options.force, + onFail: options._log, + + updateValue (el, key, charsToType) { + // in these cases, the value must only be set after all + // the characters are input because attemping to set + // a partial/invalid value results in the value being + // set to an empty string + if (needSingleValueChange(el)) { + typed += key + if (typed === charsToType) { + return $elements.setNativeProp(el, 'value', charsToType) } - }, + } - onBeforeType (totalKeys) { - // for the total number of keys we're about to - // type, ensure we raise the timeout to account - // for the delay being added to each keystroke - return cy.timeout((totalKeys * options.delay), true, 'type') - }, + return $selection.replaceSelectionContents(el, key) + }, - onBeforeSpecialCharAction (id, key) { - // don't apply any special char actions such as - // inserting new lines on {enter} or moving the - // caret / range on left or right movements - if (isTypeableButNotAnInput) { - return false + onAfterType () { + if (options.release === true) { + state('keyboardModifiers', null) + } + }, + + onBeforeType (totalKeys) { + // for the total number of keys we're about to + // type, ensure we raise the timeout to account + // for the delay being added to each keystroke + return cy.timeout(totalKeys * options.delay, true, 'type') + }, + + onEvent (...args) { + if (updateTable) { + return updateTable(...args) + } + }, + + // fires only when the 'value' + // of input/text/contenteditable + // changes + onValueChange (originalText, el) { + debug('onValueChange', originalText, el) + // contenteditable should never be called here. + // only inputs and textareas can have change events + let changeEvent = state('changeEvent') + + if (changeEvent) { + if (!changeEvent(null, true)) { + state('changeEvent', null) } - }, - onBeforeEvent (id, key, column, which) { - // if we are an element which isnt text like but we have - // a tabindex then it can receive keyboard events but - // should not fire input or textInput and should not fire - // change events - if (inputEvents.includes(column) && isTypeableButNotAnInput) { - return false - } - }, + return + } - onEvent (id, key, column, which, value) { - if (updateTable) { - // eslint-disable-next-line prefer-rest-params - return updateTable.apply(window, arguments) - } - }, + return state('changeEvent', (id, readOnly) => { + const changed = + $elements.getNativeProp(el, 'value') !== originalText - // fires only when the 'value' - // of input/text/contenteditable - // changes - onValueChange (originalText, el) { - // contenteditable should never be called here. - // only input's and textareas can have change events - let changeEvent - - changeEvent = state('changeEvent') - if (changeEvent) { - if (!changeEvent(null, true)) { - state('changeEvent', null) + if (!readOnly) { + if (changed) { + dispatchChangeEvent(el, id) } - return + state('changeEvent', null) } - return state('changeEvent', (id, readOnly) => { - const changed = $elements.getNativeProp(el, 'value') !== originalText - - if (!readOnly) { - if (changed) { - dispatchChangeEvent(el, id) - } + return changed + }) + }, - state('changeEvent', null) - } + onEnterPressed (id) { + // dont dispatch change events or handle + // submit event if we've pressed enter into + // a textarea or contenteditable - return changed - }) - }, + if (isTextarea || isContentEditable) { + return + } - onEnterPressed (id) { - // dont dispatch change events or handle - // submit event if we've pressed enter into - // a textarea or contenteditable - let changeEvent + // if our value has changed since our + // element was activated we need to + // fire a change event immediately + const changeEvent = state('changeEvent') - if (isTextarea || isContentEditable) { - return - } + if (changeEvent) { + changeEvent(id) + } - // if our value has changed since our - // element was activated we need to - // fire a change event immediately - changeEvent = state('changeEvent') - if (changeEvent) { - changeEvent(id) - } + // handle submit event handler here + return simulateSubmitHandler() + }, - // handle submit event handler here - return simulateSubmitHandler() - }, + onNoMatchingSpecialChars (chars, allChars) { + if (chars === 'tab') { + return $utils.throwErrByPath('type.tab', { onFail: options._log }) + } - onNoMatchingSpecialChars (chars, allChars) { - if (chars === '{tab}') { - return $utils.throwErrByPath('type.tab', { onFail: options._log }) - } + return $utils.throwErrByPath('type.invalid', { + onFail: options._log, + args: { chars: `{${chars}}`, allChars }, + }) + }, + }) + } - return $utils.throwErrByPath('type.invalid', { - onFail: options._log, - args: { chars, allChars }, - }) - }, + const handleFocused = function () { + // if it's the body, don't need to worry about focus + const isBody = options.$el.is('body') - }) + if (isBody) { + return type() } - const handleFocused = function () { - // if it's the body, don't need to worry about focus - if (isBody) { - return type() - } + options.ensure = { + position: true, + visibility: true, + notDisabled: true, + notAnimating: true, + notCovered: true, + notReadonly: true, + } + // if the subject is already the focused element, start typing + // we handle contenteditable children by getting the host contenteditable, + // and seeing if that is focused + // Checking first if element is focusable accounts for focusable els inside + // of contenteditables + if ($elements.isFocusedOrInFocused(options.$el.get(0))) { + debug('element is already focused, only checking readOnly property') options.ensure = { - position: true, - visibility: true, - notDisabled: true, - notCovered: true, notReadonly: true, } + } + + return $actionability.verify(cy, options.$el, options, { + onScroll ($el, type) { + return Cypress.action('cy:scrolled', $el, type) + }, - // if the subject is already the focused element, start typing - // we handle contenteditable children by getting the host contenteditable, - // and seeing if that is focused - // Checking first if element is focusable accounts for focusable els inside - // of contenteditables - if (($elements.isFocusedOrInFocused(options.$el.get(0)))) { - options.ensure = { - notReadonly: true, + onReady ($elToClick) { + // if we dont have a focused element + // or if we do and its not ourselves + // then issue the click + if ($elements.isFocusedOrInFocused($elToClick[0])) { + return type() } - } - return $actionability.verify(cy, options.$el, options, { - onScroll ($el, type) { - return Cypress.action('cy:scrolled', $el, type) - }, + // click the element first to simulate focus + // and typical user behavior in case the window + // is out of focus + // cannot just call .focus, since children of contenteditable will not receive cursor + // with .focus() + return cy.now('click', $elToClick, { + $el: $elToClick, + log: false, + verify: false, + _log: options._log, + force: true, // force the click, avoid waiting + timeout: options.timeout, + interval: options.interval, + errorOnSelect: false, + }) + .then(() => { + if (!options.force && $elements.getActiveElByDocument($elToClick[0].ownerDocument) === null) { + const node = $dom.stringify($elToClick) + const onFail = options._log + + if ($dom.isTextLike($elToClick[0])) { + $utils.throwErrByPath('type.not_actionable_textlike', { + onFail, + args: { node }, + }) + } - onReady ($elToClick) { - // if we dont have a focused element - // or if we do and its not ourselves - // then issue the click - if (!$elements.isFocusedOrInFocused($elToClick[0])) { - // click the element first to simulate focus - // and typical user behavior in case the window - // is out of focus - return cy.now('click', $elToClick, { - $el: $elToClick, - log: false, - verify: false, - _log: options._log, - force: true, // force the click, avoid waiting - timeout: options.timeout, - interval: options.interval, - }) - .then(() => { - return type() + $utils.throwErrByPath('type.not_on_typeable_element', { + onFail, + args: { node }, }) } return type() - }, - }) - } - - return handleFocused() - .then(() => { - cy.timeout($actionability.delay, true, 'type') + }) + }, + }) + } - return Promise - .delay($actionability.delay, 'type') - .then(() => { - // command which consume cy.type may - // want to handle verification themselves + return handleFocused() + .then(() => { + cy.timeout($actionability.delay, true, 'type') - if (options.verify === false) { - return options.$el - } + return Promise + .delay($actionability.delay, 'type') + .then(() => { + // command which consume cy.type may + // want to handle verification themselves - const verifyAssertions = () => { - return cy.verifyUpcomingAssertions(options.$el, options, { - onRetry: verifyAssertions, - }) - } + if (options.verify === false) { + return options.$el + } - return verifyAssertions() - }) - }) - }, + const verifyAssertions = () => { + return cy.verifyUpcomingAssertions(options.$el, options, { + onRetry: verifyAssertions, + }) + } - clear (subject, options = {}) { - _.defaults(options, { - log: true, - force: false, + return verifyAssertions() }) + }) + } - // blow up if any member of the subject - // isnt a textarea or text-like - const clear = function (el, index) { - const $el = $dom.wrap(el) + function clear (subject, options = {}) { + _.defaults(options, { + log: true, + force: false, + }) - if (options.log) { - // figure out the options which actually change the behavior of clicks - const deltaOptions = $utils.filterOutOptions(options) - - options._log = Cypress.log({ - message: deltaOptions, - $el, - consoleProps () { - return { - 'Applied To': $dom.getElements($el), - 'Elements': $el.length, - 'Options': deltaOptions, - } - }, - }) - } + // blow up if any member of the subject + // isnt a textarea or text-like + const clear = function (el) { + const $el = $dom.wrap(el) - const node = $dom.stringify($el) + if (options.log) { + // figure out the options which actually change the behavior of clicks + const deltaOptions = $utils.filterOutOptions(options) - if (!$dom.isTextLike($el.get(0))) { - const word = $utils.plural(subject, 'contains', 'is') + options._log = Cypress.log({ + message: deltaOptions, + $el, + consoleProps () { + return { + 'Applied To': $dom.getElements($el), + 'Elements': $el.length, + 'Options': deltaOptions, + } + }, + }) + } - $utils.throwErrByPath('clear.invalid_element', { - onFail: options._log, - args: { word, node }, - }) - } + const node = $dom.stringify($el) - return cy.now('type', $el, '{selectall}{del}', { - $el, - log: false, - verify: false, // handle verification ourselves - _log: options._log, - force: options.force, - timeout: options.timeout, - interval: options.interval, - }).then(() => { - if (options._log) { - options._log.snapshot().end() - } + if (!$dom.isTextLike($el.get(0))) { + const word = $utils.plural(subject, 'contains', 'is') - return null + $utils.throwErrByPath('clear.invalid_element', { + onFail: options._log, + args: { word, node }, }) } - return Promise - .resolve(subject.toArray()) - .each(clear) - .then(() => { - const verifyAssertions = () => { - return cy.verifyUpcomingAssertions(subject, options, { - onRetry: verifyAssertions, - }) + return cy.now('type', $el, '{selectall}{del}', { + $el, + log: false, + verify: false, // handle verification ourselves + _log: options._log, + force: options.force, + timeout: options.timeout, + interval: options.interval, + }).then(() => { + if (options._log) { + options._log.snapshot().end() } - return verifyAssertions() + return null }) - }, - }) + } + + return Promise + .resolve(subject.toArray()) + .each(clear) + .then(() => { + const verifyAssertions = () => { + return cy.verifyUpcomingAssertions(subject, options, { + onRetry: verifyAssertions, + }) + } + + return verifyAssertions() + }) + } + + return Commands.addAll( + { prevSubject: 'element' }, + { + type, + clear, + } + ) } diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index dbc370aab33c..198cfaea1f89 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -1,44 +1,104 @@ -const _ = require('lodash') -const Promise = require('bluebird') -const $elements = require('../dom/elements') -const $selection = require('../dom/selection') -const $document = require('../dom/document') - -const isSingleDigitRe = /^\d$/ -const isStartingDigitRe = /^\d/ +import Promise from 'bluebird' +import Debug from 'debug' +import _ from 'lodash' +import moment from 'moment' +import $utils from '../cypress/utils.coffee' +import { USKeyboard } from '../cypress/UsKeyboardLayout' +import * as $dom from '../dom' +import * as $document from '../dom/document' +import * as $elements from '../dom/elements' +import * as $selection from '../dom/selection' +import { HTMLTextLikeElement, HTMLTextLikeInputElement } from '../dom/types' +import $window from '../dom/window' + +const debug = Debug('cypress:driver:keyboard') + +export interface KeyboardModifiers { + alt: boolean + ctrl: boolean + meta: boolean + shift: boolean +} + +export interface KeyboardState { + keyboardModifiers?: KeyboardModifiers +} + +export interface ProxyState { + (arg: K): T[K] | undefined + (arg: K, arg2: T[K] | null): void +} + +export type State = ProxyState + +interface KeyDetailsPartial extends Partial { + key: string +} + +type SimulatedDefault = ( + el: HTMLTextLikeElement, + key: KeyDetails, + options: any +) => void + +interface KeyDetails { + key: string + text: string + code: string + keyCode: number + location: number + shiftKey?: string + shiftText?: string + shiftKeyCode?: number + simulatedDefault?: SimulatedDefault + simulatedDefaultOnly?: boolean + events: { + [key in KeyEventType]?: boolean; + } +} + +const dateRe = /^\d{4}-\d{2}-\d{2}/ +const monthRe = /^\d{4}-(0\d|1[0-2])/ +const weekRe = /^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])/ +const timeRe = /^([0-1]\d|2[0-3]):[0-5]\d(:[0-5]\d)?(\.[0-9]{1,3})?/ +const dateTimeRe = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}/ const charsBetweenCurlyBracesRe = /({.+?})/ -// Keyboard event map -// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values -const keyStandardMap = { - // Cypress keyboard key : Standard value - '{backspace}': 'Backspace', - '{insert}': 'Insert', - '{del}': 'Delete', - '{downarrow}': 'ArrowDown', - '{enter}': 'Enter', - '{esc}': 'Escape', - '{leftarrow}': 'ArrowLeft', - '{rightarrow}': 'ArrowRight', - '{uparrow}': 'ArrowUp', - '{home}': 'Home', - '{end}': 'End', - '{alt}': 'Alt', - '{ctrl}': 'Control', - '{meta}': 'Meta', - '{shift}': 'Shift', - '{pageup}': 'PageUp', - '{pagedown}': 'PageDown', -} - -const initialModifiers = { +const INITIAL_MODIFIERS = { alt: false, ctrl: false, meta: false, shift: false, } -const toModifiersEventOptions = (modifiers) => { +/** + * @example {meta: true, ctrl: false, shift: false, alt: true} => 5 + */ +const getModifiersValue = (modifiers: KeyboardModifiers) => { + return _ + .chain(modifiers) + .map((value, key) => { + return value && modifierValueMap[key] + }) + .sum() + .value() +} + +const modifierValueMap = { + alt: 1, + ctrl: 2, + meta: 4, + shift: 8, +} + +export type KeyEventType = + | 'keydown' + | 'keyup' + | 'keypress' + | 'input' + | 'textInput' + +const toModifiersEventOptions = (modifiers: KeyboardModifiers) => { return { altKey: modifiers.alt, ctrlKey: modifiers.ctrl, @@ -47,680 +107,1006 @@ const toModifiersEventOptions = (modifiers) => { } } -const fromModifierEventOptions = (eventOptions) => { - return _.pickBy({ +const fromModifierEventOptions = (eventOptions: { + [key: string]: string +}): KeyboardModifiers => { + return _ + .chain({ alt: eventOptions.altKey, ctrl: eventOptions.ctrlKey, meta: eventOptions.metaKey, shift: eventOptions.shiftKey, + }) + .pickBy() // remove falsy values + .defaults({ + alt: false, + ctrl: false, + meta: false, + shift: false, + }) + .value() +} - }, Boolean) +const modifiersToString = (modifiers: KeyboardModifiers) => { + return _.keys(_.pickBy(modifiers, (val) => { + return val + })).join(', ') } -const modifiersToString = (modifiers) => _.keys(_.pickBy(modifiers, Boolean)).join(', ') +const joinKeyArrayToString = (keyArr: KeyDetails[]) => { + return _.map(keyArr, (keyDetails) => { + if (keyDetails.text) return keyDetails.key -const create = (state) => { - const kb = { - getActiveModifiers () { - return _.clone(state('keyboardModifiers')) || _.clone(initialModifiers) - }, + return `{${keyDetails.key}}` + }).join('') +} - keyToStandard (key) { - return keyStandardMap[key] || key - }, +type modifierKeyDetails = KeyDetails & { + key: keyof typeof keyToModifierMap +} - charCodeMap: { - 33: 49, // ! --- 1 - 64: 50, // @ --- 2 - 35: 51, // # --- 3 - 36: 52, // $ --- 4 - 37: 53, // % --- 5 - 94: 54, // ^ --- 6 - 38: 55, // & --- 7 - 42: 56, // * --- 8 - 40: 57, // ( --- 9 - 41: 48, // ) --- 0 - 59: 186, // ; --- 186 - 58: 186, // : --- 186 - 61: 187, // = --- 187 - 43: 187, // + --- 187 - 44: 188, // , --- 188 - 60: 188, // < --- 188 - 45: 189, // - --- 189 - 95: 189, // _ --- 189 - 46: 190, // . --- 190 - 62: 190, // > --- 190 - 47: 191, // / --- 191 - 63: 191, // ? --- 191 - 96: 192, // ` --- 192 - 126: 192, // ~ --- 192 - 91: 219, // [ --- 219 - 123: 219, // { --- 219 - 92: 220, // \ --- 220 - 124: 220, // | --- 220 - 93: 221, // ] --- 221 - 125: 221, // } --- 221 - 39: 222, // ' --- 222 - 34: 222, // " --- 222 - }, +const isModifier = (details: KeyDetails): details is modifierKeyDetails => { + return !!keyToModifierMap[details.key] +} - modifierCodeMap: { - alt: 18, - ctrl: 17, - meta: 91, - shift: 16, - }, +const getFormattedKeyString = (details: KeyDetails) => { + let foundKeyString = _.findKey(keyboardMappings, { key: details.key }) - specialChars: { - '{selectall}': $selection.selectAll, - - // charCode = 46 - // no keyPress - // no textInput - // yes input (if value is actually changed) - '{del}' (el, options) { - options.charCode = 46 - options.keypress = false - options.textInput = false - options.setKey = '{del}' - - return kb.ensureKey(el, null, options, () => { - if ($selection.isCollapsed(el)) { - // if there's no text selected, delete the prev char - // if deleted char, send the input event - options.input = $selection.deleteRightOfCursor(el) - - return - } + if (foundKeyString) { + return `{${foundKeyString}}` + } - // text is selected, so delete the selection - // contents and send the input event - $selection.deleteSelectionContents(el) - options.input = true - }) - }, - - // charCode = 45 - // no keyPress - // no textInput - // no input - '{insert}' (el, options) { - options.charCode = 45 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{insert}' - - return kb.ensureKey(el, null, options) - }, - - // charCode = 8 - // no keyPress - // no textInput - // yes input (if value is actually changed) - '{backspace}' (el, options) { - options.charCode = 8 - options.keypress = false - options.textInput = false - options.setKey = '{backspace}' - - return kb.ensureKey(el, null, options, () => { - if ($selection.isCollapsed(el)) { - // if there's no text selected, delete the prev char - // if deleted char, send the input event - options.input = $selection.deleteLeftOfCursor(el) - - return - } + foundKeyString = keyToModifierMap[details.key] - // text is selected, so delete the selection - // contents and send the input event - $selection.deleteSelectionContents(el) - options.input = true - }) - }, - - // charCode = 27 - // no keyPress - // no textInput - // no input - '{esc}' (el, options) { - options.charCode = 27 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{esc}' - - return kb.ensureKey(el, null, options) - }, - - // "{tab}": (el, rng) -> - - '{{}' (el, options) { - options.key = '{' - - return kb.typeKey(el, options.key, options) - }, - - // charCode = 13 - // yes keyPress - // no textInput - // no input - // yes change (if input is different from last change event) - '{enter}' (el, options) { - options.charCode = 13 - options.textInput = false - options.input = false - options.setKey = '{enter}' - - return kb.ensureKey(el, '\n', options, () => { - $selection.replaceSelectionContents(el, '\n') - - return options.onEnterPressed(options.id) - }) - }, - - // charCode = 37 - // no keyPress - // no textInput - // no input - '{leftarrow}' (el, options) { - options.charCode = 37 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{leftarrow}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorLeft(el) - }) - }, - - // charCode = 39 - // no keyPress - // no textInput - // no input - '{rightarrow}' (el, options) { - options.charCode = 39 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{rightarrow}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorRight(el) - }) - }, - - // charCode = 38 - // no keyPress - // no textInput - // no input - '{uparrow}' (el, options) { - options.charCode = 38 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{uparrow}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorUp(el) - }) - }, - - // charCode = 40 - // no keyPress - // no textInput - // no input - '{downarrow}' (el, options) { - options.charCode = 40 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{downarrow}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorDown(el) - }) - }, - - // charCode = 36 - // no keyPress - // no textInput - // no input - '{home}' (el, options) { - options.charCode = 36 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{home}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorToLineStart(el) - }) - }, - - // charCode = 35 - // no keyPress - // no textInput - // no input - '{end}' (el, options) { - options.charCode = 35 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{end}' - - return kb.ensureKey(el, null, options, () => { - return $selection.moveCursorToLineEnd(el) - }) - }, - - // charCode = 33 - // no keyPress - // no textInput - // no input - '{pageup}' (el, options) { - options.charCode = 33 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{pageup}' - - return kb.ensureKey(el, null, options) - }, - - // charCode = 34 - // no keyPress - // no textInput - // no input - '{pagedown}' (el, options) { - options.charCode = 34 - options.keypress = false - options.textInput = false - options.input = false - options.setKey = '{pagedown}' - - return kb.ensureKey(el, null, options) - }, - }, + if (foundKeyString) { + return `<${foundKeyString}>` + } - modifierChars: { - '{alt}': 'alt', - '{option}': 'alt', + return details.key +} - '{ctrl}': 'ctrl', - '{control}': 'ctrl', +const countNumIndividualKeyStrokes = (keys: KeyDetails[]) => { + return _.countBy(keys, isModifier)['false'] +} - '{meta}': 'meta', - '{command}': 'meta', - '{cmd}': 'meta', +const findKeyDetailsOrLowercase = (key: string): KeyDetailsPartial => { + const keymap = getKeymap() + const foundKey = keymap[key] - '{shift}': 'shift', - }, + if (foundKey) return foundKey + + return _.mapKeys(keymap, (val, key) => { + return _.toLower(key) + })[_.toLower(key)] +} - type (opts = {}) { - const options = _.defaults(opts, { - delay: 10, - parseSpecialCharSequences: true, - onEvent () {}, - onBeforeEvent () {}, - onBeforeType () {}, - onValueChange () {}, - onEnterPressed () {}, - onNoMatchingSpecialChars () {}, - onBeforeSpecialCharAction () {}, +const getTextLength = (str) => _.toArray(str).length + +const getKeyDetails = (onKeyNotFound) => { + return (key: string): KeyDetails => { + let foundKey: KeyDetailsPartial + + if (getTextLength(key) === 1) { + foundKey = USKeyboard[key] || { key } + } else { + foundKey = findKeyDetailsOrLowercase(key) + } + + if (foundKey) { + const details = _.defaults({}, foundKey, { + key: '', + keyCode: 0, + code: '', + text: '', + location: 0, + events: {}, }) - const el = options.$el.get(0) + if (getTextLength(details.key) === 1) { + details.text = details.key + } - let keys = options.chars + return details + } - if (options.parseSpecialCharSequences) { - keys = options.chars.split(charsBetweenCurlyBracesRe).map((chars) => { - if (charsBetweenCurlyBracesRe.test(chars)) { - // allow special chars and modifiers to be case-insensitive - return chars.toLowerCase() - } + onKeyNotFound(key, _.keys(getKeymap()).join(', ')) - return chars - }) - } + throw Error(`Not a valid key: ${key}`) + } +} - options.onBeforeType(kb.countNumIndividualKeyStrokes(keys)) +const hasModifierBesidesShift = (modifiers: KeyboardModifiers) => { + return _.some(_.omit(modifiers, ['shift'])) +} - // should make each keystroke async to mimic - // how keystrokes come into javascript naturally - return Promise - .each(keys, (key) => { - return kb.typeChars(el, key, options) - }).then(() => { - if (options.release !== false) { - return kb.resetModifiers($document.getDocumentFromElement(el)) +/** + * @example '{foo}' => 'foo' + */ +const parseCharsBetweenCurlyBraces = (chars: string) => { + return /{(.+?)}/.exec(chars)![1] +} + +const shouldIgnoreEvent = < + T extends KeyEventType, + K extends { [key in T]?: boolean } +>( + eventName: T, + options: K + ) => { + return options[eventName] === false +} + +const shouldUpdateValue = (el: HTMLElement, key: KeyDetails) => { + if (!key.text) return true + + const bounds = $selection.getSelectionBounds(el) + const noneSelected = bounds.start === bounds.end + + if ($elements.isInput(el) || $elements.isTextarea(el)) { + if ($elements.isReadOnlyInputOrTextarea(el)) { + return false + } + + if (noneSelected) { + const ml = $elements.getNativeProp(el, 'maxLength') + + // maxlength is -1 by default when omitted + // but could also be null or undefined :-/ + // only care if we are trying to type a key + if (ml === 0 || ml > 0) { + // check if we should update the value + // and fire the input event + // as long as we're under maxlength + if (!($elements.getNativeProp(el, 'value').length < ml)) { + return false } + } + } + } + + return true +} + +const getKeymap = () => { + return { + ...keyboardMappings, + ...modifierChars, + // TODO: add the reset of USKeyboard to available keys + // ...USKeyboard, + } +} +const validateTyping = ( + el: HTMLElement, + keys: KeyDetails[], + currentIndex: number, + onFail: Function, + skipCheckUntilIndex?: number, +) => { + const chars = joinKeyArrayToString(keys.slice(currentIndex)) + const allChars = joinKeyArrayToString(keys) + + if (skipCheckUntilIndex) { + return { skipCheckUntilIndex: skipCheckUntilIndex-- } + } + + debug('validateTyping:', chars, el) + + const $el = $dom.wrap(el) + const numElements = $el.length + const isBody = $el.is('body') + const isTextLike = $dom.isTextLike(el) + + let dateChars + let monthChars + let weekChars + let timeChars + let dateTimeChars + + let isDate = false + let isTime = false + let isMonth = false + let isWeek = false + let isDateTime = false + + if ($elements.isInput(el)) { + isDate = $dom.isInputType(el, 'date') + isTime = $dom.isInputType(el, 'time') + isMonth = $dom.isInputType(el, 'month') + isWeek = $dom.isInputType(el, 'week') + isDateTime = + $dom.isInputType(el, 'datetime') || $dom.isInputType(el, 'datetime-local') + } + + const isFocusable = $elements.isFocusable($el) + const clearChars = '{selectall}{delete}' + const isClearChars = _.startsWith(chars.toLowerCase(), clearChars) + + // TODO: tabindex can't be -1 + // TODO: can't be readonly + + if (isBody) { + return {} + } + + if (!isFocusable) { + const node = $dom.stringify($el) + + if (isTextLike) { + $utils.throwErrByPath('type.not_actionable_textlike', { + onFail, + args: { node }, }) - }, + } - countNumIndividualKeyStrokes (keys) { - return _.reduce(keys, (memo, chars) => { - // special chars count as 1 keystroke - if (kb.isSpecialChar(chars)) { - return memo + 1 - // modifiers don't count as keystrokes - } + $utils.throwErrByPath('type.not_on_typeable_element', { + onFail, + args: { node }, + }) + } - if (kb.isModifier(chars)) { - return memo - } + if (numElements > 1) { + $utils.throwErrByPath('type.multiple_elements', { + onFail, + args: { num: numElements }, + }) + } - return memo + chars.length - } - , 0) + if (isClearChars) { + skipCheckUntilIndex = 2 // {selectAll}{del} is two keys + + return { skipCheckUntilIndex, isClearChars: true } + } + + if (isDate) { + dateChars = dateRe.exec(chars) + + if ( + _.isString(chars) && + dateChars && + moment(dateChars[0]).isValid() + ) { + skipCheckUntilIndex = _getEndIndex(chars, dateChars[0]) + + return { skipCheckUntilIndex } + } + + $utils.throwErrByPath('type.invalid_date', { + onFail, + // set matched date or entire char string + args: { chars: allChars }, + }) + } + + if (isMonth) { + monthChars = monthRe.exec(chars) + + if (_.isString(chars) && monthChars) { + skipCheckUntilIndex = _getEndIndex(chars, monthChars[0]) + + return { skipCheckUntilIndex } + } + + $utils.throwErrByPath('type.invalid_month', { + onFail, + args: { chars: allChars }, + }) + } + + if (isWeek) { + weekChars = weekRe.exec(chars) + + if (_.isString(chars) && weekChars) { + skipCheckUntilIndex = _getEndIndex(chars, weekChars[0]) + + return { skipCheckUntilIndex } + } + + $utils.throwErrByPath('type.invalid_week', { + onFail, + args: { chars: allChars }, + }) + } + + if (isTime) { + timeChars = timeRe.exec(chars) + + if (_.isString(chars) && timeChars) { + skipCheckUntilIndex = _getEndIndex(chars, timeChars[0]) + + return { skipCheckUntilIndex } + } + + $utils.throwErrByPath('type.invalid_time', { + onFail, + args: { chars: allChars }, + }) + } + + if (isDateTime) { + dateTimeChars = dateTimeRe.exec(chars) + + if (_.isString(chars) && dateTimeChars) { + skipCheckUntilIndex = _getEndIndex(chars, dateTimeChars[0]) + + return { skipCheckUntilIndex } + } + + $utils.throwErrByPath('type.invalid_dateTime', { + onFail, + args: { chars: allChars }, + }) + } + + return {} +} + +function _getEndIndex (str, substr) { + return str.indexOf(substr) + substr.length +} + +// Simulated default actions for select few keys. +const simulatedDefaultKeyMap: { [key: string]: SimulatedDefault } = { + Enter: (el, key, options) => { + if ($elements.isContentEditable(el) || $elements.isTextarea(el)) { + key.events.input = $selection.replaceSelectionContents(el, '\n') + } else { + key.events.input = false + } + + options.onEnterPressed() + }, + Delete: (el, key) => { + key.events.input = $selection.deleteRightOfCursor(el) + }, + Backspace: (el, key) => { + key.events.input = $selection.deleteLeftOfCursor(el) + }, + ArrowLeft: (el) => { + return $selection.moveCursorLeft(el) + }, + ArrowRight: (el) => { + return $selection.moveCursorRight(el) + }, + + ArrowUp: (el) => { + return $selection.moveCursorUp(el) + }, + + ArrowDown: (el) => { + return $selection.moveCursorDown(el) + }, + + Home: (el) => { + return $selection.moveCursorToLineStart(el) + }, + End: (el) => { + return $selection.moveCursorToLineEnd(el) + }, +} + +const modifierChars = { + alt: USKeyboard.Alt, + option: USKeyboard.Alt, + + ctrl: USKeyboard.Control, + control: USKeyboard.Control, + + meta: USKeyboard.Meta, + command: USKeyboard.Meta, + cmd: USKeyboard.Meta, + + shift: USKeyboard.Shift, +} + +const keyboardMappings: { [key: string]: KeyDetailsPartial } = { + selectAll: { + key: 'selectAll', + simulatedDefault: (el) => { + const doc = $document.getDocumentFromElement(el) + + return $selection.selectAll(doc) + }, + simulatedDefaultOnly: true, + }, + moveToStart: { + key: 'moveToStart', + simulatedDefault: (el) => { + const doc = $document.getDocumentFromElement(el) + + return $selection.moveSelectionToStart(doc) }, + simulatedDefaultOnly: true, + }, + moveToEnd: { + key: 'moveToEnd', + simulatedDefault: (el) => { + const doc = $document.getDocumentFromElement(el) + + return $selection.moveSelectionToEnd(doc) + }, + simulatedDefaultOnly: true, + }, + + del: USKeyboard.Delete, + backspace: USKeyboard.Backspace, + esc: USKeyboard.Escape, + enter: USKeyboard.Enter, + rightArrow: USKeyboard.ArrowRight, + leftArrow: USKeyboard.ArrowLeft, + upArrow: USKeyboard.ArrowUp, + downArrow: USKeyboard.ArrowDown, + home: USKeyboard.Home, + end: USKeyboard.End, + insert: USKeyboard.Insert, + pageUp: USKeyboard.PageUp, + pageDown: USKeyboard.PageDown, + '{': USKeyboard.BracketLeft, +} - typeChars (el, chars, options) { - options = _.clone(options) +const keyToModifierMap = { + Alt: 'alt', + Control: 'ctrl', + Meta: 'meta', + Shift: 'shift', +} - switch (false) { - case !kb.isSpecialChar(chars): { - return Promise - .resolve(kb.handleSpecialChars(el, chars, options)) - .delay(options.delay) - } - case !kb.isModifier(chars): { - return Promise - .resolve(kb.handleModifier(el, chars, options)) - .delay(options.delay) - } - case !charsBetweenCurlyBracesRe.test(chars): { - // between curly braces, but not a valid special - // char or modifier - const allChars = _.keys(kb.specialChars).concat(_.keys(kb.modifierChars)).join(', ') - - return Promise - .resolve(options.onNoMatchingSpecialChars(chars, allChars)) - .delay(options.delay) - } - default: { - return Promise - .each(chars.split(''), (char) => { - return Promise - .resolve(kb.typeKey(el, char, options)) - .delay(options.delay) - }) +export interface typeOptions { + $el: JQuery + chars: string + force?: boolean + simulated?: boolean + release?: boolean + _log?: any + delay?: number + onError?: Function + onEvent?: Function + onBeforeEvent?: Function + onFocusChange?: Function + onBeforeType?: Function + onAfterType?: Function + onValueChange?: Function + onEnterPressed?: Function + onNoMatchingSpecialChars?: Function + onBeforeSpecialCharAction?: Function +} + +export class Keyboard { + constructor (private state: State) { + null + } + + type (opts: typeOptions) { + const options = _.defaults({}, opts, { + delay: 0, + force: false, + simulated: false, + onError: _.noop, + onEvent: _.noop, + onBeforeEvent: _.noop, + onFocusChange: _.noop, + onBeforeType: _.noop, + onAfterType: _.noop, + onValueChange: _.noop, + onEnterPressed: _.noop, + onNoMatchingSpecialChars: _.noop, + onBeforeSpecialCharAction: _.noop, + parseSpecialCharSequences: true, + onFail: _.noop, + }) + + if (options.force) { + options.simulated = true + } + + debug('type:', options.chars, options) + + const el = options.$el.get(0) + const doc = $document.getDocumentFromElement(el) + + let keys: string[] + + if (!options.parseSpecialCharSequences) { + keys = options.chars.split('') + } else { + keys = _.flatMap( + options.chars.split(charsBetweenCurlyBracesRe), + (chars) => { + if (charsBetweenCurlyBracesRe.test(chars)) { + // allow special chars and modifiers to be case-insensitive + return parseCharsBetweenCurlyBraces(chars) //.toLowerCase() + } + + // ignore empty strings + return _.filter(_.split(chars, '')) } + ) + } + + const keyDetailsArr = _.map( + keys, + getKeyDetails(options.onNoMatchingSpecialChars) + ) + + const numKeys = countNumIndividualKeyStrokes(keyDetailsArr) + + options.onBeforeType(numKeys) + + const getActiveEl = (doc: Document) => { + if (options.force) { + return options.$el.get(0) } - }, - getKeyCode (key) { - const code = key.charCodeAt(0) + const activeEl = $elements.getActiveElByDocument(doc) || doc.body - return kb.charCodeMap[code] != null ? kb.charCodeMap[code] : code - }, + return activeEl + } - getAsciiCode (key) { - const code = key.charCodeAt(0) + let _skipCheckUntilIndex: number | undefined = 0 - return code - }, + const typeKeyFns = _.map( + keyDetailsArr, + (key: KeyDetails, currentKeyIndex: number) => { + return () => { + debug('typing key:', key.key) - simulateKey (el, eventType, key, options) { - // bail if we've said not to fire this specific event - // in our options + const activeEl = getActiveEl(doc) - let charCode - let keyCode - let which + _skipCheckUntilIndex = _skipCheckUntilIndex && _skipCheckUntilIndex - 1 - if (options[eventType] === false) { - return true - } + if (!_skipCheckUntilIndex) { + const { skipCheckUntilIndex, isClearChars } = validateTyping( + activeEl, + keyDetailsArr, + currentKeyIndex, + options.onFail, + _skipCheckUntilIndex, + ) - key = options.key != null ? options.key : key + _skipCheckUntilIndex = skipCheckUntilIndex - let keys = true - let otherKeys = true + if ( + _skipCheckUntilIndex + && $elements.isNeedSingleValueChangeInputElement(activeEl) + ) { + const originalText = $elements.getNativeProp(activeEl, 'value') - const event = new Event(eventType, { - bubbles: true, - cancelable: eventType !== 'input', - }) + debug('skip validate until:', _skipCheckUntilIndex) + const keysToType = keyDetailsArr.slice(currentKeyIndex, currentKeyIndex + _skipCheckUntilIndex) - switch (eventType) { - case 'keydown': case 'keyup': { - keyCode = options.charCode != null ? options.charCode : kb.getKeyCode(key.toUpperCase()) + _.each(keysToType, (key) => { + key.simulatedDefaultOnly = true + key.simulatedDefault = _.noop + }) - charCode = 0 - which = keyCode - break - } - case 'keypress': { - const asciiCode = options.charCode != null ? options.charCode : kb.getAsciiCode(key) + _.last(keysToType)!.simulatedDefault = () => { + options.onValueChange(originalText, activeEl) - charCode = asciiCode - keyCode = asciiCode - which = asciiCode - break - } - case 'textInput': { - charCode = 0 - keyCode = 0 - which = 0 - otherKeys = false - _.extend(event, { - data: key, - }) - - break - } + const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType) + + debug('setting element value', valToSet, activeEl) - case 'input': { - keys = false - otherKeys = false - break + return $elements.setNativeProp( + activeEl as HTMLTextLikeInputElement, + 'value', + valToSet + ) + } + } + } else { + debug('skipping validation due to *skipCheckUntilIndex*', _skipCheckUntilIndex) + } + + if (key.simulatedDefaultOnly && key.simulatedDefault) { + key.simulatedDefault(activeEl, key, options) + + return null + } + + this.typeSimulatedKey(activeEl, key, options) + debug('returning null') + + return null } + } + ) + + const modifierKeys = _.filter(keyDetailsArr, isModifier) + + if (options.simulated && !options.delay) { + _.each(typeKeyFns, (fn) => { + return fn() + }) - default: null + if (options.release !== false) { + _.each(modifierKeys, (key) => { + return this.simulatedKeyup(getActiveEl(doc), key, options) + }) } - if (otherKeys) { - _.extend(event, { - location: 0, - repeat: false, + options.onAfterType() + + return + } + + return Promise + .each(typeKeyFns, (fn) => { + return Promise + .try(fn) + .delay(options.delay) + }) + .then(() => { + if (options.release !== false) { + return Promise.map(modifierKeys, (key) => { + return this.simulatedKeyup(getActiveEl(doc), key, options) }) + } + + return [] + }) + .then(options.onAfterType) + } + + fireSimulatedEvent ( + el: HTMLElement, + eventType: KeyEventType, + keyDetails: KeyDetails, + opts: { + id: string + onEvent?: (...args) => boolean + onBeforeEvent?: (...args) => boolean + } + ) { + debug('fireSimulatedEvent', eventType, keyDetails) + + const options = _.defaults(opts, { + onBeforeEvent: _.noop, + onEvent: _.noop, + }) + const win = $window.getWindowByElement(el) + const text = keyDetails.text + + let charCode: number | undefined + let keyCode: number | undefined + let which: number | undefined + let data: string | undefined + let location: number | undefined = keyDetails.location || 0 + let key: string | undefined + let code: string | undefined = keyDetails.code + let eventConstructor = 'KeyboardEvent' + let cancelable = true + let addModifiers = true + + switch (eventType) { + case 'keydown': + case 'keyup': { + keyCode = keyDetails.keyCode + which = keyDetails.keyCode + key = keyDetails.key + charCode = 0 + break + } + + case 'keypress': { + const charCodeAt = keyDetails.text.charCodeAt(0) - _.extend(event, toModifiersEventOptions(kb.getActiveModifiers())) + charCode = charCodeAt + keyCode = charCodeAt + which = charCodeAt + key = keyDetails.key + break } - if (keys) { - // special key like "{enter}" might have 'key = \n' - // in which case the original intent will be in options.setKey - // "normal" keys will have their value in "key" argument itself - const standardKey = kb.keyToStandard(options.setKey || key) + case 'textInput': // lowercase in IE11 + eventConstructor = 'TextEvent' + addModifiers = false + charCode = 0 + keyCode = 0 + which = 0 + location = undefined + data = text + break + + case 'input': + eventConstructor = 'InputEvent' + addModifiers = false + data = text + location = undefined + cancelable = false + break + default: { + throw new Error(`Invalid event: ${eventType}`) + } + } + + let eventOptions: EventInit & { + view?: Window + data?: string + repeat?: boolean + } = {} - _.extend(event, { + if (addModifiers) { + const modifierEventOptions = toModifiersEventOptions(this.getActiveModifiers()) + + eventOptions = { + ...eventOptions, + ...modifierEventOptions, + repeat: false, + } + } + + eventOptions = { + ...eventOptions, + ..._.omitBy( + { + bubbles: true, + cancelable, + key, + code, charCode, - detail: 0, - key: standardKey, + location, keyCode, - layerX: 0, - layerY: 0, - pageX: 0, - pageY: 0, - view: options.window, which, - }) - } + data, + detail: 0, + view: win, + }, + _.isUndefined + ), + } + + let event: Event + + debug('event options:', eventType, eventOptions) + + if (eventConstructor === 'TextEvent') { + event = document.createEvent('TextEvent') + // @ts-ignore + event.initTextEvent( + eventType, + eventOptions.bubbles, + eventOptions.cancelable, + eventOptions.view, + eventOptions.data, + 1 + // eventOptions.locale + ) + /*1: IE11 Input method param*/ + // event.initEvent(eventType) + + // or is IE + } else { + // For some reason we can't set certain props on Keyboard Events in chrome < 63. + // So we'll use the plain Event constructor + // event = new win[eventConstructor](eventType, eventOptions) + event = new win['Event'](eventType, eventOptions) + _.extend(event, eventOptions) + } + + const dispatched = el.dispatchEvent(event) + + debug(`dispatched [${eventType}] on ${el}`) + const formattedKeyString = getFormattedKeyString(keyDetails) + + debug('format string', formattedKeyString) + options.onEvent(options.id, formattedKeyString, eventType, which, dispatched) + + return dispatched + } - const args = [options.id, key, eventType, which] + getActiveModifiers () { + return _.clone(this.state('keyboardModifiers')) || _.clone(INITIAL_MODIFIERS) + } - // give the driver a chance to bail on this event - // if we return false here - if (options.onBeforeEvent.apply(this, args) === false) { - return - } + getModifierKeyDetails (key: KeyDetails) { + const modifiers = this.getActiveModifiers() + const details = { ...key, modifiers: getModifiersValue(modifiers) } - const dispatched = el.dispatchEvent(event) + if (modifiers.shift && details.shiftKey) { + details.key = details.shiftKey + } - args.push(dispatched) + if (modifiers.shift && details.shiftKeyCode) { + details.keyCode = details.shiftKeyCode + } - options.onEvent.apply(this, args) + if (modifiers.shift && details.shiftText) { + details.text = details.shiftText + } - return dispatched - }, + // If any modifier besides shift is pressed, no text. + if (hasModifierBesidesShift(modifiers)) { + details.text = '' + } - typeKey (el, key, options) { - return kb.ensureKey(el, key, options, () => { - const isDigit = isSingleDigitRe.test(key) - const isNumberInputType = $elements.isInput(el) && $elements.isType(el, 'number') + return details + } - if (isNumberInputType) { - const { selectionStart } = el - const valueLength = $elements.getNativeProp(el, 'value').length - const isDigitsInText = isStartingDigitRe.test(options.chars) - const isValidCharacter = (key === '.') || ((key === '-') && valueLength) - const { prevChar } = options + flagModifier (key: modifierKeyDetails, setTo = true) { + debug('handleModifier', key.key) + const modifier = keyToModifierMap[key.key] - if (!isDigit && (isDigitsInText || !isValidCharacter || (selectionStart !== 0))) { - options.prevChar = key + // do nothing if already activated + if (Boolean(this.getActiveModifiers()[modifier]) === setTo) { + return false + } - return - } + const _activeModifiers = this.getActiveModifiers() - // only type '.' and '-' if it is the first symbol and there already is a value, or if - // '.' or '-' are appended to a digit. If not, value cannot be set. - if (isDigit && ((prevChar === '.') || ((prevChar === '-') && !valueLength))) { - options.prevChar = key - key = prevChar + key - } - } + _activeModifiers[modifier] = setTo - return options.updateValue(el, key) - }) - }, + this.state('keyboardModifiers', _activeModifiers) - ensureKey (el, key, options, fn?: Function) { - _.defaults(options, { - prevText: null, - }) + return true + } + + simulatedKeydown (el: HTMLElement, _key: KeyDetails, options: any) { + if (isModifier(_key)) { + const didFlag = this.flagModifier(_key) - options.id = _.uniqueId('char') - // options.beforeKey = el.value + if (!didFlag) { + return null + } - const maybeUpdateValueAndFireInput = () => { - // only call this function if we haven't been told not to - if (fn && (options.onBeforeSpecialCharAction(options.id, options.key) !== false)) { - let prevText + _key.events.keyup = false + } - if (!$elements.isContentEditable(el)) { - prevText = $elements.getNativeProp(el, 'value') - } + const key = this.getModifierKeyDetails(_key) - fn.call(this) + if (!key.text) { + key.events.input = false + key.events.keypress = false + key.events.textInput = false + } - if ((options.prevText === null) && !$elements.isContentEditable(el)) { - options.prevText = prevText - options.onValueChange(options.prevText, el) - } - } + let elToType - return kb.simulateKey(el, 'input', key, options) - } + options.id = _.uniqueId('char') - if (kb.simulateKey(el, 'keydown', key, options)) { - if (kb.simulateKey(el, 'keypress', key, options)) { - if (kb.simulateKey(el, 'textInput', key, options)) { - let ml + debug( + 'typeSimulatedKey options:', + _.pick(options, ['keydown', 'keypress', 'textInput', 'input', 'id']) + ) - if ($elements.isInput(el) || $elements.isTextarea(el)) { - ml = el.maxLength - } + if ( + shouldIgnoreEvent('keydown', key.events) || + this.fireSimulatedEvent(el, 'keydown', key, options) + ) { + elToType = this.getActiveEl(options) - // maxlength is -1 by default when omitted - // but could also be null or undefined :-/ - // only cafe if we are trying to type a key - if (((ml === 0) || (ml > 0)) && key) { - // check if we should update the value - // and fire the input event - // as long as we're under maxlength + if (key.key === 'Enter' && $elements.isInput(elToType)) { + key.events.textInput = false + } - if ($elements.getNativeProp(el, 'value').length < ml) { - maybeUpdateValueAndFireInput() - } - } else { - maybeUpdateValueAndFireInput() - } - } + if ($elements.isReadOnlyInputOrTextarea(elToType)) { + key.events.textInput = false + } + + if ( + shouldIgnoreEvent('keypress', key.events) || + this.fireSimulatedEvent(elToType, 'keypress', key, options) + ) { + if ( + shouldIgnoreEvent('textInput', key.events) || + this.fireSimulatedEvent(elToType, 'textInput', key, options) + ) { + return this.performSimulatedDefault(elToType, key, options) } } + } + } - return kb.simulateKey(el, 'keyup', key, options) - }, + typeSimulatedKey (el: HTMLElement, key: KeyDetails, options) { + debug('typeSimulatedKey', key.key, el) + _.defaults(options, { + prevText: null, + }) - isSpecialChar (chars) { - return _.includes(_.keys(kb.specialChars), chars) - }, + const isFocusable = $elements.isFocusable($dom.wrap(el)) + const isTextLike = $elements.isTextLike(el) - handleSpecialChars (el, chars, options) { - options.key = chars + const isTypeableButNotTextLike = !isTextLike && isFocusable - return kb.specialChars[chars].call(this, el, options) - }, + if (isTypeableButNotTextLike) { + key.events.input = false + key.events.textInput = false + } - isModifier (chars) { - return _.includes(_.keys(kb.modifierChars), chars) - }, + this.simulatedKeydown(el, key, options) + const elToKeyup = this.getActiveEl(options) + + this.simulatedKeyup(elToKeyup, key, options) + } - handleModifier (el, chars, options) { - const modifier = kb.modifierChars[chars] + simulatedKeyup (el: HTMLElement, _key: KeyDetails, options: any) { + if (shouldIgnoreEvent('keyup', _key.events)) { + debug('simulatedKeyup: ignoring event') + delete _key.events.keyup - const activeModifiers = kb.getActiveModifiers() + return + } + + if (isModifier(_key)) { + this.flagModifier(_key, false) + } + + const key = this.getModifierKeyDetails(_key) + + this.fireSimulatedEvent(el, 'keyup', key, options) + } + + getSimulatedDefaultForKey (key: KeyDetails) { + debug('getSimulatedDefaultForKey', key.key) + if (key.simulatedDefault) return key.simulatedDefault + + let nonShiftModifierPressed = hasModifierBesidesShift(this.getActiveModifiers()) + + debug({ nonShiftModifierPressed, key }) + if (!nonShiftModifierPressed && simulatedDefaultKeyMap[key.key]) { + return simulatedDefaultKeyMap[key.key] + } + + return (el: HTMLElement) => { + if (!shouldUpdateValue(el, key)) { + debug('skip typing key', false) + key.events.input = false - // do nothing if already activated - if (activeModifiers[modifier]) { return } - activeModifiers[modifier] = true - state('keyboardModifiers', activeModifiers) + // noop if not in a text-editable + const ret = $selection.replaceSelectionContents(el, key.text) - return kb.simulateModifier(el, 'keydown', modifier, options) - }, + debug('replaceSelectionContents:', key.text, ret) + } + } - simulateModifier (el, eventType, modifier, options) { - return kb.simulateKey(el, eventType, null, _.extend(options, { - charCode: kb.modifierCodeMap[modifier], - id: _.uniqueId('char'), - key: `<${modifier}>`, - })) - }, + getActiveEl (options) { + const el = options.$el.get(0) - resetModifiers (doc) { - const activeEl = $elements.getActiveElByDocument(doc) - const activeModifiers = kb.getActiveModifiers() - - for (let modifier in activeModifiers) { - const isActivated = activeModifiers[modifier] - - activeModifiers[modifier] = false - state('keyboardModifiers', _.clone(activeModifiers)) - if (isActivated) { - kb.simulateModifier(activeEl, 'keyup', modifier, { - window, - onBeforeEvent () { }, - onEvent () { }, - }) + if (options.force) { + return el + } + + const doc = $document.getDocumentFromElement(el) + + return $elements.getActiveElByDocument(doc) || doc.body + } + + performSimulatedDefault (el: HTMLElement, key: KeyDetails, options: any) { + debug('performSimulatedDefault', key.key) + const simulatedDefault = this.getSimulatedDefaultForKey(key) + + if ($elements.isTextLike(el)) { + if ($elements.isInput(el) || $elements.isTextarea(el)) { + const curText = $elements.getNativeProp(el, 'value') + + simulatedDefault(el, key, options) + if (key.events.input !== false) { + options.onValueChange(curText, el) } + } else { + // el is contenteditable + simulatedDefault(el, key, options) } - }, + + shouldIgnoreEvent('input', key.events) || + this.fireSimulatedEvent(el, 'input', key, options) + + return + } + + return simulatedDefault(el, key, options) } +} - return kb +const create = (state) => { + return new Keyboard(state) } export { create, - toModifiersEventOptions, + getKeymap, modifiersToString, + toModifiersEventOptions, fromModifierEventOptions, } diff --git a/packages/driver/src/cy/mouse.js b/packages/driver/src/cy/mouse.js index 772478dcebe4..16f7f0e52f38 100644 --- a/packages/driver/src/cy/mouse.js +++ b/packages/driver/src/cy/mouse.js @@ -57,7 +57,7 @@ const shouldFireMouseMoveEvents = (targetEl, lastHoveredEl, fromElViewport, coor const create = (state, keyboard, focused) => { const mouse = { _getDefaultMouseOptions (x, y, win) { - const _activeModifiers = keyboard.getActiveModifiers() + const _activeModifiers = keyboard.getActiveModifiers(state) const modifiersEventOptions = $Keyboard.toModifiersEventOptions(_activeModifiers) const coordsEventOptions = toCoordsEventOptions(x, y, win) @@ -333,12 +333,6 @@ const create = (state, keyboard, focused) => { return mouseDownEvents } - if ($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el)) { - if (!$elements.isNeedSingleValueChangeInputElement(el)) { - $selection.moveSelectionToEnd(el) - } - } - //# retrieve the first focusable $el in our parent chain const $elToFocus = $elements.getFirstFocusableEl($(el)) @@ -358,6 +352,13 @@ const create = (state, keyboard, focused) => { } } + if ($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el)) { + if (!$elements.isNeedSingleValueChangeInputElement(el)) { + debug('moveSelectionToEnd due to click') + $selection.moveSelectionToEnd($dom.getDocumentFromElement(el)) + } + } + return mouseDownEvents }, diff --git a/packages/driver/src/cypress.coffee b/packages/driver/src/cypress.coffee index e54f4141d129..7b294d059234 100644 --- a/packages/driver/src/cypress.coffee +++ b/packages/driver/src/cypress.coffee @@ -15,11 +15,13 @@ $Commands = require("./cypress/commands") $Cookies = require("./cypress/cookies") $Cy = require("./cypress/cy") $Events = require("./cypress/events") +$Keyboard = require("./cy/keyboard") $SetterGetter = require("./cypress/setter_getter") $Log = require("./cypress/log") $Location = require("./cypress/location") $LocalStorage = require("./cypress/local_storage") $Mocha = require("./cypress/mocha") +$Mouse = require("./cy/mouse") $Runner = require("./cypress/runner") $Server = require("./cypress/server") $Screenshot = require("./cypress/screenshot") @@ -466,10 +468,12 @@ class $Cypress Commands: $Commands dom: $dom errorMessages: $errorMessages + Keyboard: $Keyboard Location: $Location Log: $Log LocalStorage: $LocalStorage Mocha: $Mocha + Mouse: $Mouse Runner: $Runner Server: $Server Screenshot: $Screenshot diff --git a/packages/driver/src/cypress/UsKeyboardLayout.js b/packages/driver/src/cypress/UsKeyboardLayout.js new file mode 100644 index 000000000000..35316ce7dde7 --- /dev/null +++ b/packages/driver/src/cypress/UsKeyboardLayout.js @@ -0,0 +1,398 @@ +module.exports = { + USKeyboard: { + '0': { keyCode: 48, key: '0', code: 'Digit0' }, + '1': { keyCode: 49, key: '1', code: 'Digit1' }, + '2': { keyCode: 50, key: '2', code: 'Digit2' }, + '3': { keyCode: 51, key: '3', code: 'Digit3' }, + '4': { keyCode: 52, key: '4', code: 'Digit4' }, + '5': { keyCode: 53, key: '5', code: 'Digit5' }, + '6': { keyCode: 54, key: '6', code: 'Digit6' }, + '7': { keyCode: 55, key: '7', code: 'Digit7' }, + '8': { keyCode: 56, key: '8', code: 'Digit8' }, + '9': { keyCode: 57, key: '9', code: 'Digit9' }, + Power: { key: 'Power', code: 'Power' }, + Eject: { key: 'Eject', code: 'Eject' }, + Abort: { keyCode: 3, code: 'Abort', key: 'Cancel' }, + Help: { keyCode: 6, code: 'Help', key: 'Help' }, + Backspace: { keyCode: 8, code: 'Backspace', key: 'Backspace' }, + Tab: { keyCode: 9, code: 'Tab', key: 'Tab' }, + Numpad5: { + keyCode: 12, + shiftKeyCode: 101, + key: 'Clear', + code: 'Numpad5', + shiftKey: '5', + location: 3, + }, + NumpadEnter: { + keyCode: 13, + code: 'NumpadEnter', + key: 'Enter', + text: '\r', + location: 3, + }, + Enter: { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + '\r': { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + '\n': { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + ShiftLeft: { keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1 }, + ShiftRight: { keyCode: 16, code: 'ShiftRight', key: 'Shift', location: 2 }, + ControlLeft: { + keyCode: 17, + code: 'ControlLeft', + key: 'Control', + location: 1, + }, + ControlRight: { + keyCode: 17, + code: 'ControlRight', + key: 'Control', + location: 2, + }, + AltLeft: { keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1 }, + AltRight: { keyCode: 18, code: 'AltRight', key: 'Alt', location: 2 }, + Pause: { keyCode: 19, code: 'Pause', key: 'Pause' }, + CapsLock: { keyCode: 20, code: 'CapsLock', key: 'CapsLock' }, + Escape: { keyCode: 27, code: 'Escape', key: 'Escape' }, + Convert: { keyCode: 28, code: 'Convert', key: 'Convert' }, + NonConvert: { keyCode: 29, code: 'NonConvert', key: 'NonConvert' }, + Space: { keyCode: 32, code: 'Space', key: ' ' }, + Numpad9: { + keyCode: 33, + shiftKeyCode: 105, + key: 'PageUp', + code: 'Numpad9', + shiftKey: '9', + location: 3, + }, + PageUp: { keyCode: 33, code: 'PageUp', key: 'PageUp' }, + Numpad3: { + keyCode: 34, + shiftKeyCode: 99, + key: 'PageDown', + code: 'Numpad3', + shiftKey: '3', + location: 3, + }, + PageDown: { keyCode: 34, code: 'PageDown', key: 'PageDown' }, + End: { keyCode: 35, code: 'End', key: 'End' }, + Numpad1: { + keyCode: 35, + shiftKeyCode: 97, + key: 'End', + code: 'Numpad1', + shiftKey: '1', + location: 3, + }, + Home: { keyCode: 36, code: 'Home', key: 'Home' }, + Numpad7: { + keyCode: 36, + shiftKeyCode: 103, + key: 'Home', + code: 'Numpad7', + shiftKey: '7', + location: 3, + }, + ArrowLeft: { keyCode: 37, code: 'ArrowLeft', key: 'ArrowLeft' }, + Numpad4: { + keyCode: 37, + shiftKeyCode: 100, + key: 'ArrowLeft', + code: 'Numpad4', + shiftKey: '4', + location: 3, + }, + Numpad8: { + keyCode: 38, + shiftKeyCode: 104, + key: 'ArrowUp', + code: 'Numpad8', + shiftKey: '8', + location: 3, + }, + ArrowUp: { keyCode: 38, code: 'ArrowUp', key: 'ArrowUp' }, + ArrowRight: { keyCode: 39, code: 'ArrowRight', key: 'ArrowRight' }, + Numpad6: { + keyCode: 39, + shiftKeyCode: 102, + key: 'ArrowRight', + code: 'Numpad6', + shiftKey: '6', + location: 3, + }, + Numpad2: { + keyCode: 40, + shiftKeyCode: 98, + key: 'ArrowDown', + code: 'Numpad2', + shiftKey: '2', + location: 3, + }, + ArrowDown: { keyCode: 40, code: 'ArrowDown', key: 'ArrowDown' }, + Select: { keyCode: 41, code: 'Select', key: 'Select' }, + Open: { keyCode: 43, code: 'Open', key: 'Execute' }, + PrintScreen: { keyCode: 44, code: 'PrintScreen', key: 'PrintScreen' }, + Insert: { keyCode: 45, code: 'Insert', key: 'Insert' }, + Numpad0: { + keyCode: 45, + shiftKeyCode: 96, + key: 'Insert', + code: 'Numpad0', + shiftKey: '0', + location: 3, + }, + Delete: { keyCode: 46, code: 'Delete', key: 'Delete' }, + NumpadDecimal: { + keyCode: 46, + shiftKeyCode: 110, + code: 'NumpadDecimal', + key: '\u0000', + shiftKey: '.', + location: 3, + }, + Digit0: { keyCode: 48, code: 'Digit0', shiftKey: ')', key: '0' }, + Digit1: { keyCode: 49, code: 'Digit1', shiftKey: '!', key: '1' }, + Digit2: { keyCode: 50, code: 'Digit2', shiftKey: '@', key: '2' }, + Digit3: { keyCode: 51, code: 'Digit3', shiftKey: '#', key: '3' }, + Digit4: { keyCode: 52, code: 'Digit4', shiftKey: '$', key: '4' }, + Digit5: { keyCode: 53, code: 'Digit5', shiftKey: '%', key: '5' }, + Digit6: { keyCode: 54, code: 'Digit6', shiftKey: '^', key: '6' }, + Digit7: { keyCode: 55, code: 'Digit7', shiftKey: '&', key: '7' }, + Digit8: { keyCode: 56, code: 'Digit8', shiftKey: '*', key: '8' }, + Digit9: { keyCode: 57, code: 'Digit9', shiftKey: '(', key: '9' }, + KeyA: { keyCode: 65, code: 'KeyA', shiftKey: 'A', key: 'a' }, + KeyB: { keyCode: 66, code: 'KeyB', shiftKey: 'B', key: 'b' }, + KeyC: { keyCode: 67, code: 'KeyC', shiftKey: 'C', key: 'c' }, + KeyD: { keyCode: 68, code: 'KeyD', shiftKey: 'D', key: 'd' }, + KeyE: { keyCode: 69, code: 'KeyE', shiftKey: 'E', key: 'e' }, + KeyF: { keyCode: 70, code: 'KeyF', shiftKey: 'F', key: 'f' }, + KeyG: { keyCode: 71, code: 'KeyG', shiftKey: 'G', key: 'g' }, + KeyH: { keyCode: 72, code: 'KeyH', shiftKey: 'H', key: 'h' }, + KeyI: { keyCode: 73, code: 'KeyI', shiftKey: 'I', key: 'i' }, + KeyJ: { keyCode: 74, code: 'KeyJ', shiftKey: 'J', key: 'j' }, + KeyK: { keyCode: 75, code: 'KeyK', shiftKey: 'K', key: 'k' }, + KeyL: { keyCode: 76, code: 'KeyL', shiftKey: 'L', key: 'l' }, + KeyM: { keyCode: 77, code: 'KeyM', shiftKey: 'M', key: 'm' }, + KeyN: { keyCode: 78, code: 'KeyN', shiftKey: 'N', key: 'n' }, + KeyO: { keyCode: 79, code: 'KeyO', shiftKey: 'O', key: 'o' }, + KeyP: { keyCode: 80, code: 'KeyP', shiftKey: 'P', key: 'p' }, + KeyQ: { keyCode: 81, code: 'KeyQ', shiftKey: 'Q', key: 'q' }, + KeyR: { keyCode: 82, code: 'KeyR', shiftKey: 'R', key: 'r' }, + KeyS: { keyCode: 83, code: 'KeyS', shiftKey: 'S', key: 's' }, + KeyT: { keyCode: 84, code: 'KeyT', shiftKey: 'T', key: 't' }, + KeyU: { keyCode: 85, code: 'KeyU', shiftKey: 'U', key: 'u' }, + KeyV: { keyCode: 86, code: 'KeyV', shiftKey: 'V', key: 'v' }, + KeyW: { keyCode: 87, code: 'KeyW', shiftKey: 'W', key: 'w' }, + KeyX: { keyCode: 88, code: 'KeyX', shiftKey: 'X', key: 'x' }, + KeyY: { keyCode: 89, code: 'KeyY', shiftKey: 'Y', key: 'y' }, + KeyZ: { keyCode: 90, code: 'KeyZ', shiftKey: 'Z', key: 'z' }, + MetaLeft: { keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1 }, + MetaRight: { keyCode: 92, code: 'MetaRight', key: 'Meta', location: 2 }, + ContextMenu: { keyCode: 93, code: 'ContextMenu', key: 'ContextMenu' }, + NumpadMultiply: { + keyCode: 106, + code: 'NumpadMultiply', + key: '*', + location: 3, + }, + NumpadAdd: { keyCode: 107, code: 'NumpadAdd', key: '+', location: 3 }, + NumpadSubtract: { + keyCode: 109, + code: 'NumpadSubtract', + key: '-', + location: 3, + }, + NumpadDivide: { keyCode: 111, code: 'NumpadDivide', key: '/', location: 3 }, + F1: { keyCode: 112, code: 'F1', key: 'F1' }, + F2: { keyCode: 113, code: 'F2', key: 'F2' }, + F3: { keyCode: 114, code: 'F3', key: 'F3' }, + F4: { keyCode: 115, code: 'F4', key: 'F4' }, + F5: { keyCode: 116, code: 'F5', key: 'F5' }, + F6: { keyCode: 117, code: 'F6', key: 'F6' }, + F7: { keyCode: 118, code: 'F7', key: 'F7' }, + F8: { keyCode: 119, code: 'F8', key: 'F8' }, + F9: { keyCode: 120, code: 'F9', key: 'F9' }, + F10: { keyCode: 121, code: 'F10', key: 'F10' }, + F11: { keyCode: 122, code: 'F11', key: 'F11' }, + F12: { keyCode: 123, code: 'F12', key: 'F12' }, + F13: { keyCode: 124, code: 'F13', key: 'F13' }, + F14: { keyCode: 125, code: 'F14', key: 'F14' }, + F15: { keyCode: 126, code: 'F15', key: 'F15' }, + F16: { keyCode: 127, code: 'F16', key: 'F16' }, + F17: { keyCode: 128, code: 'F17', key: 'F17' }, + F18: { keyCode: 129, code: 'F18', key: 'F18' }, + F19: { keyCode: 130, code: 'F19', key: 'F19' }, + F20: { keyCode: 131, code: 'F20', key: 'F20' }, + F21: { keyCode: 132, code: 'F21', key: 'F21' }, + F22: { keyCode: 133, code: 'F22', key: 'F22' }, + F23: { keyCode: 134, code: 'F23', key: 'F23' }, + F24: { keyCode: 135, code: 'F24', key: 'F24' }, + NumLock: { keyCode: 144, code: 'NumLock', key: 'NumLock' }, + ScrollLock: { keyCode: 145, code: 'ScrollLock', key: 'ScrollLock' }, + AudioVolumeMute: { + keyCode: 173, + code: 'AudioVolumeMute', + key: 'AudioVolumeMute', + }, + AudioVolumeDown: { + keyCode: 174, + code: 'AudioVolumeDown', + key: 'AudioVolumeDown', + }, + AudioVolumeUp: { + keyCode: 175, + code: 'AudioVolumeUp', + key: 'AudioVolumeUp', + }, + MediaTrackNext: { + keyCode: 176, + code: 'MediaTrackNext', + key: 'MediaTrackNext', + }, + MediaTrackPrevious: { + keyCode: 177, + code: 'MediaTrackPrevious', + key: 'MediaTrackPrevious', + }, + MediaStop: { keyCode: 178, code: 'MediaStop', key: 'MediaStop' }, + MediaPlayPause: { + keyCode: 179, + code: 'MediaPlayPause', + key: 'MediaPlayPause', + }, + Semicolon: { keyCode: 186, code: 'Semicolon', shiftKey: ':', key: ';' }, + Equal: { keyCode: 187, code: 'Equal', shiftKey: '+', key: '=' }, + NumpadEqual: { keyCode: 187, code: 'NumpadEqual', key: '=', location: 3 }, + Comma: { keyCode: 188, code: 'Comma', shiftKey: '<', key: ',' }, + Minus: { keyCode: 189, code: 'Minus', shiftKey: '_', key: '-' }, + Period: { keyCode: 190, code: 'Period', shiftKey: '>', key: '.' }, + Slash: { keyCode: 191, code: 'Slash', shiftKey: '?', key: '/' }, + Backquote: { keyCode: 192, code: 'Backquote', shiftKey: '~', key: '`' }, + BracketLeft: { keyCode: 219, code: 'BracketLeft', shiftKey: '{', key: '[' }, + Backslash: { keyCode: 220, code: 'Backslash', shiftKey: '|', key: '\\' }, + BracketRight: { + keyCode: 221, + code: 'BracketRight', + shiftKey: '}', + key: ']', + }, + Quote: { keyCode: 222, code: 'Quote', shiftKey: '"', key: '\'' }, + AltGraph: { keyCode: 225, code: 'AltGraph', key: 'AltGraph' }, + Props: { keyCode: 247, code: 'Props', key: 'CrSel' }, + Cancel: { keyCode: 3, key: 'Cancel', code: 'Abort' }, + Clear: { keyCode: 12, key: 'Clear', code: 'Numpad5', location: 3 }, + Shift: { keyCode: 16, key: 'Shift', code: 'ShiftLeft', location: 1 }, + Control: { keyCode: 17, key: 'Control', code: 'ControlLeft', location: 1 }, + Alt: { keyCode: 18, key: 'Alt', code: 'AltLeft', location: 1 }, + Accept: { keyCode: 30, key: 'Accept' }, + ModeChange: { keyCode: 31, key: 'ModeChange' }, + ' ': { keyCode: 32, key: ' ', code: 'Space' }, + Print: { keyCode: 42, key: 'Print' }, + Execute: { keyCode: 43, key: 'Execute', code: 'Open' }, + '\u0000': { + keyCode: 46, + key: '\u0000', + code: 'NumpadDecimal', + location: 3, + }, + a: { keyCode: 65, key: 'a', code: 'KeyA' }, + b: { keyCode: 66, key: 'b', code: 'KeyB' }, + c: { keyCode: 67, key: 'c', code: 'KeyC' }, + d: { keyCode: 68, key: 'd', code: 'KeyD' }, + e: { keyCode: 69, key: 'e', code: 'KeyE' }, + f: { keyCode: 70, key: 'f', code: 'KeyF' }, + g: { keyCode: 71, key: 'g', code: 'KeyG' }, + h: { keyCode: 72, key: 'h', code: 'KeyH' }, + i: { keyCode: 73, key: 'i', code: 'KeyI' }, + j: { keyCode: 74, key: 'j', code: 'KeyJ' }, + k: { keyCode: 75, key: 'k', code: 'KeyK' }, + l: { keyCode: 76, key: 'l', code: 'KeyL' }, + m: { keyCode: 77, key: 'm', code: 'KeyM' }, + n: { keyCode: 78, key: 'n', code: 'KeyN' }, + o: { keyCode: 79, key: 'o', code: 'KeyO' }, + p: { keyCode: 80, key: 'p', code: 'KeyP' }, + q: { keyCode: 81, key: 'q', code: 'KeyQ' }, + r: { keyCode: 82, key: 'r', code: 'KeyR' }, + s: { keyCode: 83, key: 's', code: 'KeyS' }, + t: { keyCode: 84, key: 't', code: 'KeyT' }, + u: { keyCode: 85, key: 'u', code: 'KeyU' }, + v: { keyCode: 86, key: 'v', code: 'KeyV' }, + w: { keyCode: 87, key: 'w', code: 'KeyW' }, + x: { keyCode: 88, key: 'x', code: 'KeyX' }, + y: { keyCode: 89, key: 'y', code: 'KeyY' }, + z: { keyCode: 90, key: 'z', code: 'KeyZ' }, + Meta: { keyCode: 91, key: 'Meta', code: 'MetaLeft', location: 1 }, + + '*': { keyCode: 56, code: 'Digit8', key: '*' }, + // '*': { keyCode: 106, key: '*', code: 'NumpadMultiply', location: 3 }, + + '+': { keyCode: 187, code: 'Equal', key: '+' }, + // '+': { keyCode: 107, key: '+', code: 'NumpadAdd', location: 3 }, + + '-': { keyCode: 189, code: 'Minus', key: '-' }, + // '-': { keyCode: 109, key: '-', code: 'NumpadSubtract', location: 3 }, + + '/': { keyCode: 191, code: 'Slash', key: '/' }, + // '/': { keyCode: 111, key: '/', code: 'NumpadDivide', location: 3 }, + + ';': { keyCode: 186, key: ';', code: 'Semicolon' }, + '=': { keyCode: 187, key: '=', code: 'Equal' }, + ',': { keyCode: 188, key: ',', code: 'Comma' }, + '.': { keyCode: 190, key: '.', code: 'Period' }, + '`': { keyCode: 192, key: '`', code: 'Backquote' }, + '[': { keyCode: 219, key: '[', code: 'BracketLeft' }, + '\\': { keyCode: 220, key: '\\', code: 'Backslash' }, + ']': { keyCode: 221, key: ']', code: 'BracketRight' }, + '\'': { keyCode: 222, key: '\'', code: 'Quote' }, + Attn: { keyCode: 246, key: 'Attn' }, + CrSel: { keyCode: 247, key: 'CrSel', code: 'Props' }, + ExSel: { keyCode: 248, key: 'ExSel' }, + EraseEof: { keyCode: 249, key: 'EraseEof' }, + Play: { keyCode: 250, key: 'Play' }, + ZoomOut: { keyCode: 251, key: 'ZoomOut' }, + ')': { keyCode: 48, key: ')', code: 'Digit0' }, + '!': { keyCode: 49, key: '!', code: 'Digit1' }, + '@': { keyCode: 50, key: '@', code: 'Digit2' }, + '#': { keyCode: 51, key: '#', code: 'Digit3' }, + $: { keyCode: 52, key: '$', code: 'Digit4' }, + '%': { keyCode: 53, key: '%', code: 'Digit5' }, + '^': { keyCode: 54, key: '^', code: 'Digit6' }, + '&': { keyCode: 55, key: '&', code: 'Digit7' }, + '(': { keyCode: 57, key: '(', code: 'Digit9' }, + A: { keyCode: 65, key: 'A', code: 'KeyA' }, + B: { keyCode: 66, key: 'B', code: 'KeyB' }, + C: { keyCode: 67, key: 'C', code: 'KeyC' }, + D: { keyCode: 68, key: 'D', code: 'KeyD' }, + E: { keyCode: 69, key: 'E', code: 'KeyE' }, + F: { keyCode: 70, key: 'F', code: 'KeyF' }, + G: { keyCode: 71, key: 'G', code: 'KeyG' }, + H: { keyCode: 72, key: 'H', code: 'KeyH' }, + I: { keyCode: 73, key: 'I', code: 'KeyI' }, + J: { keyCode: 74, key: 'J', code: 'KeyJ' }, + K: { keyCode: 75, key: 'K', code: 'KeyK' }, + L: { keyCode: 76, key: 'L', code: 'KeyL' }, + M: { keyCode: 77, key: 'M', code: 'KeyM' }, + N: { keyCode: 78, key: 'N', code: 'KeyN' }, + O: { keyCode: 79, key: 'O', code: 'KeyO' }, + P: { keyCode: 80, key: 'P', code: 'KeyP' }, + Q: { keyCode: 81, key: 'Q', code: 'KeyQ' }, + R: { keyCode: 82, key: 'R', code: 'KeyR' }, + S: { keyCode: 83, key: 'S', code: 'KeyS' }, + T: { keyCode: 84, key: 'T', code: 'KeyT' }, + U: { keyCode: 85, key: 'U', code: 'KeyU' }, + V: { keyCode: 86, key: 'V', code: 'KeyV' }, + W: { keyCode: 87, key: 'W', code: 'KeyW' }, + X: { keyCode: 88, key: 'X', code: 'KeyX' }, + Y: { keyCode: 89, key: 'Y', code: 'KeyY' }, + Z: { keyCode: 90, key: 'Z', code: 'KeyZ' }, + ':': { keyCode: 186, key: ':', code: 'Semicolon' }, + '<': { keyCode: 188, key: '<', code: 'Comma' }, + _: { keyCode: 189, key: '_', code: 'Minus' }, + '>': { keyCode: 190, key: '>', code: 'Period' }, + '?': { keyCode: 191, key: '?', code: 'Slash' }, + '~': { keyCode: 192, key: '~', code: 'Backquote' }, + '{': { keyCode: 219, key: '{', code: 'BracketLeft' }, + '|': { keyCode: 220, key: '|', code: 'Backslash' }, + '}': { keyCode: 221, key: '}', code: 'BracketRight' }, + '"': { keyCode: 222, key: '"', code: 'Quote' }, + }, +} diff --git a/packages/driver/src/cypress/cy.coffee b/packages/driver/src/cypress/cy.coffee index 027f6bf70349..8d6ffaebf9fa 100644 --- a/packages/driver/src/cypress/cy.coffee +++ b/packages/driver/src/cypress/cy.coffee @@ -167,9 +167,6 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> contentWindow.SVGElement.prototype.blur = -> focused.interceptBlur(@) - contentWindow.HTMLInputElement.prototype.select = -> - $selection.interceptSelect.call(@) - contentWindow.document.hasFocus = -> focused.documentHasFocus.call(@) diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index 53c16cfd19d2..1daba9a2eced 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -117,7 +117,16 @@ module.exports = { > {{node}} - Cypress considers a 'textarea', any 'element' with a 'contenteditable' attribute, or any 'input' with a 'type' attribute of 'text', 'password', 'email', 'number', 'date', 'week', 'month', 'time', 'datetime', 'datetime-local', 'search', 'url', or 'tel' to be valid clearable elements. + A clearable element matches one of the following selectors: + 'a[href]' + 'area[href]' + 'input' + 'select' + 'textarea' + 'button' + 'iframe' + '[tabindex]' + '[contenteditable]' """ clearCookie: @@ -941,6 +950,7 @@ module.exports = { invalid_month: "Typing into a month input with #{cmd('type')} requires a valid month with the format 'yyyy-MM'. You passed: {{chars}}" invalid_week: "Typing into a week input with #{cmd('type')} requires a valid week with the format 'yyyy-Www', where W is the literal character 'W' and ww is the week number (00-53). You passed: {{chars}}" invalid_time: "Typing into a time input with #{cmd('type')} requires a valid time with the format 'HH:mm', 'HH:mm:ss' or 'HH:mm:ss.SSS', where HH is 00-23, mm is 00-59, ss is 00-59, and SSS is 000-999. You passed: {{chars}}" + invalid_dateTime: "Typing into a datetime input with #{cmd('type')} requires a valid datetime with the format 'yyyy-MM-ddThh:mm', for example '2017-06-01T08:30'. You passed: {{chars}}" multiple_elements: "#{cmd('type')} can only be called on a single element. Your subject contained {{num}} elements." not_on_typeable_element: """ #{cmd('type')} failed because it requires a valid typeable element. @@ -949,7 +959,25 @@ module.exports = { > {{node}} - Cypress considers the 'body', 'textarea', any 'element' with a 'tabindex' or 'contenteditable' attribute, or any 'input' with a 'type' attribute of 'text', 'password', 'email', 'number', 'date', 'week', 'month', 'time', 'datetime', 'datetime-local', 'search', 'url', or 'tel' to be valid typeable elements. + A typeable element matches one of the following selectors: + 'a[href]' + 'area[href]' + 'input' + 'select' + 'textarea' + 'button' + 'iframe' + '[tabindex]' + '[contenteditable]' + """ + not_actionable_textlike: """ + #{cmd('type')} failed because it targeted a disabled element. + + The element typed into was: + + > {{node}} + + You should ensure the element does not have an attribute named 'disabled' before typing into it. """ tab: "{tab} isn't a supported character sequence. You'll want to use the command #{cmd('tab')}, which is not ready yet, but when it is done that's what you'll use." wrong_type: "#{cmd('type')} can only accept a String or Number. You passed in: '{{chars}}'" diff --git a/packages/driver/src/cypress/setter_getter.d.ts b/packages/driver/src/cypress/setter_getter.d.ts new file mode 100644 index 000000000000..82fee6810c92 --- /dev/null +++ b/packages/driver/src/cypress/setter_getter.d.ts @@ -0,0 +1,4 @@ +type SetterGetter = { + (key: K): T + (key: K, value: T): T +} diff --git a/packages/driver/src/dom/document.js b/packages/driver/src/dom/document.ts similarity index 69% rename from packages/driver/src/dom/document.js rename to packages/driver/src/dom/document.ts index 7ba4b493e5e7..083983037402 100644 --- a/packages/driver/src/dom/document.js +++ b/packages/driver/src/dom/document.ts @@ -2,7 +2,8 @@ const $jquery = require('./jquery') const docNode = window.Node.DOCUMENT_NODE -const isDocument = (obj) => { +//TODO: make this not allow jquery +const isDocument = (obj: HTMLElement | Document): obj is Document => { try { if ($jquery.isJquery(obj)) { obj = obj[0] @@ -19,15 +20,15 @@ const hasActiveWindow = (doc) => { return !!doc.defaultView } -const getDocumentFromElement = (el) => { +const getDocumentFromElement = (el: HTMLElement): Document => { if (isDocument(el)) { return el } - return el.ownerDocument + return el.ownerDocument as Document } -module.exports = { +export { isDocument, hasActiveWindow, diff --git a/packages/driver/src/dom/elements.ts b/packages/driver/src/dom/elements.ts index fa1ced27bf39..2f702e571df7 100644 --- a/packages/driver/src/dom/elements.ts +++ b/packages/driver/src/dom/elements.ts @@ -1,12 +1,12 @@ // NOT patched jquery import $ from 'jquery' -import * as $jquery from './jquery' -import * as $window from './window' -import * as $document from './document' +import _ from '../config/lodash' import $utils from '../cypress/utils.coffee' +import * as $document from './document' +import * as $jquery from './jquery' import * as $selection from './selection' -import _ from '../config/lodash' import { parentHasDisplayNone } from './visibility' +import * as $window from './window' const { wrap } = $jquery @@ -23,7 +23,19 @@ const focusableSelectors = [ '[tabindex]', '[contenteditable]', ] -const inputTypeNeedSingleValueChangeRe = /^(date|time|month|week)$/ +const focusableWhenNotDisabledSelectors = [ + 'a[href]', + 'area[href]', + 'input', + 'select', + 'textarea', + 'button', + 'iframe', + '[tabindex]', + '[contenteditable]', +] + +const inputTypeNeedSingleValueChangeRe = /^(date|time|week|month|datetime-local)$/ const canSetSelectionRangeElementRe = /^(text|search|URL|tel|password)$/ declare global { @@ -200,6 +212,18 @@ const _getType = function () { throw new Error('this should never happen, cannot get type') } +const _getMaxLength = function () { + if (isInput(this)) { + return descriptor('HTMLInputElement', 'maxLength').get + } + + if (isTextarea(this)) { + return descriptor('HTMLTextAreaElement', 'maxLength').get + } + + throw new Error('this should never happen, cannot get maxLength') +} + const nativeGetters = { value: _getValue, isContentEditable: _isContentEditable, @@ -210,6 +234,7 @@ const nativeGetters = { activeElement: descriptor('Document', 'activeElement').get, body: descriptor('Document', 'body').get, frameElement: Object.getOwnPropertyDescriptor(window, 'frameElement')!.get, + maxLength: _getMaxLength, } const nativeSetters = { @@ -394,14 +419,10 @@ const isFocused = (el) => { } } -const isFocusedOrInFocused = (el) => { +const isFocusedOrInFocused = (el: HTMLElement) => { const doc = $document.getDocumentFromElement(el) - const { activeElement, body } = doc - - if (activeElementIsDefault(activeElement, body)) { - return false - } + const { activeElement } = doc let elToCheckCurrentlyFocused @@ -430,24 +451,28 @@ const isElement = function (obj): obj is HTMLElement | JQuery { } } +const isDesignModeDocumentElement = (el: HTMLElement) => { + return isElement(el) && getTagName(el) === 'html' && isContentEditable(el) +} /** * The element can be activeElement, receive focus events, and also receive keyboard events */ -const isFocusable = ($el: JQuery) => { - // matches a focusable selector - if (_.some(focusableSelectors, (sel) => $el.is(sel))) { - return true - } - - // when document.designMode === 'on' (indicated by truthy isContentEditable) - // the documentElement will be focusable - if ( - (isElement($el[0]) && getTagName($el[0]) === 'html' && isContentEditable($el[0])) - ) { - return true - } +const isFocusable = ($el: JQuery) => { + return ( + _.some(focusableSelectors, (sel) => $el.is(sel)) || + isDesignModeDocumentElement($el.get(0)) + ) +} - return false +/** + * The element can be activeElement, receive focus events, and also receive keyboard events + * OR, it is a disabled element that would have been focusable + */ +const isFocusableWhenNotDisabled = ($el: JQuery) => { + return ( + _.some(focusableWhenNotDisabledSelectors, (sel) => $el.is(sel)) || + isDesignModeDocumentElement($el.get(0)) + ) } const isW3CRendered = (el) => { @@ -462,7 +487,7 @@ const isW3CFocusable = (el) => { type JQueryOrEl = JQuery | T -const isType = function (el: JQueryOrEl, type) { +const isInputType = function (el: JQueryOrEl, type) { el = ([] as HTMLElement[]).concat($jquery.unwrap(el))[0] if (!isInput(el) && !isButton(el)) { @@ -523,6 +548,20 @@ const isSelector = ($el: JQuery, selector) => { return $el.is(selector) } +const isDisabled = ($el: JQuery) => { + return $el.prop('disabled') +} + +const isReadOnlyInputOrTextarea = ( + el: HTMLInputElement | HTMLTextAreaElement +) => { + return el.readOnly +} + +const isReadOnlyInput = ($el: JQuery) => { + return $el.prop('readonly') +} + const isDetached = ($el) => { return !isAttached($el) } @@ -629,7 +668,7 @@ const isTextLike = function (el: HTMLElement): el is HTMLTextLikeElement { } const type = (type) => { if (isInput(el)) { - return isType(el, type) + return isInputType(el, type) } return false @@ -661,7 +700,7 @@ const isTextLike = function (el: HTMLElement): el is HTMLTextLikeElement { const isInputAllowingImplicitFormSubmission = function ($el) { const type = (type) => { - return isType($el, type) + return isInputType($el, type) } // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission @@ -781,13 +820,14 @@ const getFirstFocusableEl = ($el: JQuery) => { return getFirstFocusableEl($el.parent()) } +const getActiveElByDocument = (doc: Document): HTMLElement | null => { + const activeElement = getNativeProp(doc, 'activeElement') -const getActiveElByDocument = (doc) => { - const activeEl = getNativeProp(doc, 'activeElement') - - if (activeEl) return activeEl + if (isFocused(activeElement)) { + return activeElement as HTMLElement + } - return getNativeProp(doc, 'body') + return null } const getFirstParentWithTagName = ($el, tagName) => { @@ -1015,6 +1055,10 @@ export { isSelector, isScrollOrAuto, isFocusable, + isFocusableWhenNotDisabled, + isDisabled, + isReadOnlyInput, + isReadOnlyInputOrTextarea, isW3CFocusable, isAttached, isDetached, @@ -1034,7 +1078,7 @@ export { isInput, isIframe, isTextarea, - isType, + isInputType, isFocused, isFocusedOrInFocused, isInputAllowingImplicitFormSubmission, diff --git a/packages/driver/src/dom/index.js b/packages/driver/src/dom/index.js index 307317c7e8a5..fc919df18048 100644 --- a/packages/driver/src/dom/index.js +++ b/packages/driver/src/dom/index.js @@ -6,10 +6,10 @@ const $visibility = require('./visibility') const $coordinates = require('./coordinates') const { isWindow, getWindowByElement } = $window -const { isDocument } = $document +const { isDocument, getDocumentFromElement } = $document const { wrap, unwrap, isJquery, query } = $jquery const { isVisible, isHidden, getReasonIsHidden } = $visibility -const { isType, isFocusable, isElement, isScrollable, isFocused, stringify, getElements, getContainsSelector, getFirstDeepestElement, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent } = $elements +const { isInputType, isFocusable, isElement, isScrollable, isFocused, stringify, getElements, getContainsSelector, getFirstDeepestElement, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent } = $elements const { getCoordsByPosition, getElementPositioning, getElementCoordinatesByPosition, getElementAtPointFromViewport, getElementCoordinatesByPositionRelativeToXY } = $coordinates const { getHostContenteditable, getSelectionBounds } = require('./selection') const isDom = (obj) => { @@ -26,7 +26,7 @@ module.exports = { query, unwrap, isDom, - isType, + isInputType, isVisible, isHidden, isFocusable, @@ -57,4 +57,5 @@ module.exports = { getElementCoordinatesByPositionRelativeToXY, getHostContenteditable, getSelectionBounds, + getDocumentFromElement, } diff --git a/packages/driver/src/dom/selection.ts b/packages/driver/src/dom/selection.ts index c01687245748..5f210a3e9679 100644 --- a/packages/driver/src/dom/selection.ts +++ b/packages/driver/src/dom/selection.ts @@ -1,8 +1,7 @@ +import * as $dom from '../dom' import * as $document from './document' import * as $elements from './elements' -const INTERNAL_STATE = '__Cypress_state__' - const _getSelectionBoundsFromTextarea = (el) => { return { start: $elements.getNativeProp(el, 'selectionStart'), @@ -18,63 +17,54 @@ const _getSelectionBoundsFromInput = function (el) { } } - const internalState = el[INTERNAL_STATE] - - if (internalState) { - return { - start: internalState.start, - end: internalState.end, - } - } + const doc = $document.getDocumentFromElement(el) + const range = _getSelectionRange(doc) return { - start: 0, - end: 0, + start: range.startOffset, + end: range.endOffset, } } -const _getSelectionBoundsFromContentEditable = function (el) { - const doc = $document.getDocumentFromElement(el) +const _getSelectionRange = (doc: Document) => { + const sel = doc.getSelection() - if (doc.getSelection) { - //# global selection object - const sel = doc.getSelection() + // selection has at least one range (most always 1; only 0 at page load) + if (sel && sel.rangeCount) { + // get the first (usually only) range obj + return sel.getRangeAt(0) + } - //# selection has at least one range (most always 1; only 0 at page load) - if (sel.rangeCount) { - //# get the first (usually only) range obj - const range = sel.getRangeAt(0) + return doc.createRange() +} - //# if div[contenteditable] > text - const hostContenteditable = getHostContenteditable(range.commonAncestorContainer) +const _getSelectionBoundsFromContentEditable = function (el) { + const doc = $document.getDocumentFromElement(el) + const range = _getSelectionRange(doc) + const hostContenteditable = getHostContenteditable(range.commonAncestorContainer) - if (hostContenteditable === el) { - return { - start: range.startOffset, - end: range.endOffset, - } - } + if (hostContenteditable === el) { + return { + start: range.startOffset, + end: range.endOffset, } } return { - start: null, - end: null, + start: 0, + end: 0, } } -//# TODO get ACTUAL caret position in contenteditable, not line - -const _replaceSelectionContentsContentEditable = function (el, text) { - const doc = $document.getDocumentFromElement(el) - - //# NOTE: insertText will also handle '\n', and render newlines - $elements.callNativeMethod(doc, 'execCommand', 'insertText', true, text) +// TODO get ACTUAL caret position in contenteditable, not line +const _replaceSelectionContentsWithExecCommand = function (doc, text) { + // NOTE: insertText will also handle '\n', and render newlines + return $elements.callNativeMethod(doc, 'execCommand', 'insertText', true, text) } -//# Keeping around native implementation -//# for same reasons as listed below -//# +// Keeping around native implementation +// for same reasons as listed below +// // if text is "\n" // return _insertNewlineIntoContentEditable(el) // doc = $document.getDocumentFromElement(el) @@ -128,130 +118,95 @@ const getHostContenteditable = function (el) { curEl = curEl.parentElement } - //# if there's no host contenteditable, we must be in designmode - //# so act as if the original element is the host contenteditable - //# TODO: remove this when we no longer click before type and move - //# cursor to the end + // if there's no host contenteditable, we must be in designmode + // so act as if the body element is the host contenteditable if (!_hasContenteditableAttr(curEl)) { - return el + return el.ownerDocument.body } return curEl } -const _getInnerLastChild = function (el) { - while (el.lastChild) { - el = el.lastChild - } - - return el -} - +/** + * + * @param {HTMLElement} el + * @returns {Selection} + */ const _getSelectionByEl = function (el) { const doc = $document.getDocumentFromElement(el) - return doc.getSelection() + return doc.getSelection()! } -const deleteSelectionContents = function (el) { +const deleteSelectionContents = function (el: HTMLElement) { if ($elements.isContentEditable(el)) { const doc = $document.getDocumentFromElement(el) $elements.callNativeMethod(doc, 'execCommand', 'delete', false, null) - return + return false } - //# for input and textarea, update selected text with empty string return replaceSelectionContents(el, '') } const setSelectionRange = function (el, start, end) { - if ($elements.canSetSelectionRangeElement(el)) { - $elements.callNativeMethod(el, 'setSelectionRange', start, end) - - return - } - - //# NOTE: Some input elements have mobile implementations - //# and thus may not always have a cursor, so calling setSelectionRange will throw. - //# we are assuming desktop here, so we store our own internal state. - - el[INTERNAL_STATE] = { - start, - end, - } + $elements.callNativeMethod(el, 'setSelectionRange', start, end) } const deleteRightOfCursor = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { + if ($elements.canSetSelectionRangeElement(el)) { const { start, end } = getSelectionBounds(el) - if (start === $elements.getNativeProp(el, 'value').length) { - //# nothing to delete, nothing to right of selection - return false + if (start === end) { + setSelectionRange(el, start, end + 1) } - setSelectionRange(el, start, end + 1) - deleteSelectionContents(el) - - //# successful delete - return true + return deleteSelectionContents(el) } - if ($elements.isContentEditable(el)) { - const selection = _getSelectionByEl(el) - - $elements.callNativeMethod(selection, 'modify', 'extend', 'forward', 'character') - - if ($elements.getNativeProp(selection, 'isCollapsed')) { - //# there's nothing to delete - return false - } - - deleteSelectionContents(el) + const selection = _getSelectionByEl(el) - //# successful delete - return true + if (selection.isCollapsed) { + $elements.callNativeMethod( + selection, + 'modify', + 'extend', + 'forward', + 'character' + ) } + deleteSelectionContents(el) + return false } const deleteLeftOfCursor = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { + if ($elements.canSetSelectionRangeElement(el)) { const { start, end } = getSelectionBounds(el) - if (start === 0) { - //# there's nothing to delete, nothing before cursor - return false + if (start === end) { + setSelectionRange(el, start - 1, end) } - setSelectionRange(el, start - 1, end) - deleteSelectionContents(el) - - //# successful delete - return true + return deleteSelectionContents(el) } - if ($elements.isContentEditable(el)) { - //# there is no 'backwardDelete' command for execCommand, so use the Selection API - const selection = _getSelectionByEl(el) - - $elements.callNativeMethod(selection, 'modify', 'extend', 'backward', 'character') - - if (selection.isCollapsed) { - //# there's nothing to delete - //# since extending the selection didn't do anything - return false - } - - deleteSelectionContents(el) + const selection = _getSelectionByEl(el) - //# successful delete - return true + if (selection.isCollapsed) { + $elements.callNativeMethod( + selection, + 'modify', + 'extend', + 'backward', + 'character' + ) } + deleteSelectionContents(el) + return false } @@ -260,7 +215,7 @@ const _collapseInputOrTextArea = (el, toIndex) => { } const moveCursorLeft = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { + if ($elements.canSetSelectionRangeElement(el)) { const { start, end } = getSelectionBounds(el) if (start !== end) { @@ -274,26 +229,22 @@ const moveCursorLeft = function (el) { return setSelectionRange(el, start - 1, start - 1) } - if ($elements.isContentEditable(el)) { - const selection = _getSelectionByEl(el) + // if ($elements.isContentEditable(el)) { + const selection = _getSelectionByEl(el) - return $elements.callNativeMethod(selection, 'modify', 'move', 'backward', 'character') - } + return $elements.callNativeMethod( + selection, + 'modify', + 'move', + 'backward', + 'character' + ) + // } } -// const _getSelectionRangeByEl = function (el) { -// const sel = _getSelectionByEl(el) - -// if (sel.rangeCount > 0) { -// return sel.getRangeAt(0) -// } - -// throw new Error('No selection in document') -// } - -//# Keeping around native implementation -//# for same reasons as listed below -//# +// Keeping around native implementation +// for same reasons as listed below +// // range = _getSelectionRangeByEl(el) // if !range.collapsed // return range.collapse(true) @@ -304,23 +255,27 @@ const moveCursorLeft = function (el) { // range.setEnd(range.startContainer, newOffset) const moveCursorRight = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { + if ($elements.canSetSelectionRangeElement(el)) { const { start, end } = getSelectionBounds(el) if (start !== end) { return _collapseInputOrTextArea(el, end) } - //# Don't worry about moving past the end of the string - //# nothing will happen and there is no error. + // Don't worry about moving past the end of the string + // nothing will happen and there is no error. return setSelectionRange(el, start + 1, end + 1) } - if ($elements.isContentEditable(el)) { - const selection = _getSelectionByEl(el) + const selection = _getSelectionByEl(el) - return $elements.callNativeMethod(selection, 'modify', 'move', 'forward', 'character') - } + return $elements.callNativeMethod( + selection, + 'modify', + 'move', + 'forward', + 'character' + ) } const moveCursorUp = (el) => { @@ -333,10 +288,10 @@ const moveCursorDown = (el) => { const _moveCursorUpOrDown = function (el, up) { if ($elements.isInput(el)) { - //# on an input, instead of moving the cursor - //# we want to perform the native browser action - //# which is to increment the step/interval - if ($elements.isType(el, 'number')) { + // on an input, instead of moving the cursor + // we want to perform the native browser action + // which is to increment the step/interval + if ($elements.isInputType(el, 'number')) { if (up) { if (typeof el.stepUp === 'function') { el.stepUp() @@ -354,10 +309,13 @@ const _moveCursorUpOrDown = function (el, up) { if ($elements.isTextarea(el) || $elements.isContentEditable(el)) { const selection = _getSelectionByEl(el) - return $elements.callNativeMethod(selection, 'modify', + return $elements.callNativeMethod( + selection, + 'modify', 'move', up ? 'backward' : 'forward', - 'line') + 'line' + ) } } @@ -379,36 +337,38 @@ const _moveCursorToLineStartOrEnd = function (el, toStart) { } } -const isCollapsed = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { +const isCollapsed = (el: HTMLElement) => { + if ($elements.canSetSelectionRangeElement(el)) { const { start, end } = getSelectionBounds(el) return start === end } - if ($elements.isContentEditable(el)) { - const selection = _getSelectionByEl(el) + const doc = $document.getDocumentFromElement(el) - return selection.isCollapsed - } + return _getSelectionRange(doc).collapsed } -const selectAll = function (el) { - if ($elements.isTextarea(el) || $elements.isInput(el)) { +const selectAll = function (doc) { + const el = _getActive(doc) + + if ($elements.canSetSelectionRangeElement(el)) { setSelectionRange(el, 0, $elements.getNativeProp(el, 'value').length) return } - if ($elements.isContentEditable(el)) { - const doc = $document.getDocumentFromElement(el) - - return $elements.callNativeMethod(doc, 'execCommand', 'selectAll', false, null) - } + return $elements.callNativeMethod( + doc, + 'execCommand', + 'selectAll', + false, + null + ) } -//# Keeping around native implementation -//# for same reasons as listed below -//# +// Keeping around native implementation +// for same reasons as listed below +// // range = _getSelectionRangeByEl(el) // range.selectNodeContents(el) // range.deleteContents() @@ -419,77 +379,111 @@ const selectAll = function (el) { // range.setEnd(endTextNode, endTextNode.length) const getSelectionBounds = function (el) { - //# this function works for input, textareas, and contentEditables - switch (false) { - case !$elements.isInput(el): + // this function works for input, textareas, and contentEditables + switch (true) { + case !!$elements.isInput(el): return _getSelectionBoundsFromInput(el) - case !$elements.isTextarea(el): + case !!$elements.isTextarea(el): return _getSelectionBoundsFromTextarea(el) - case !$elements.isContentEditable(el): + case !!$elements.isContentEditable(el): return _getSelectionBoundsFromContentEditable(el) default: return { - start: null, - end: null, + start: 0, + end: 0, } } } -const moveSelectionToEnd = function (el) { - let length +const moveSelectionToEnd = (doc) => { + return _moveSelectionTo(false, doc) +} - if ($elements.isInput(el) || $elements.isTextarea(el)) { - ({ length } = $elements.getNativeProp(el, 'value')) +const moveSelectionToStart = (doc) => { + return _moveSelectionTo(true, doc) +} - return setSelectionRange(el, length, length) - } +const _moveSelectionTo = function (toStart, doc) { + const el = _getActive(doc) - if ($elements.isContentEditable(el)) { - //# NOTE: can't use execCommand API here because we would have - //# to selectAll and then collapse so we use the Selection API - const doc = $document.getDocumentFromElement(el) - const range = $elements.callNativeMethod(doc, 'createRange') - const hostContenteditable = getHostContenteditable(el) - let lastTextNode = _getInnerLastChild(hostContenteditable) + if ($elements.canSetSelectionRangeElement(el)) { + let cursorPosition - if (lastTextNode.tagName === 'BR') { - lastTextNode = lastTextNode.parentNode + if (toStart) { + cursorPosition = 0 + } else { + cursorPosition = $elements.getNativeProp(el, 'value').length } - range.setStart(lastTextNode, lastTextNode.length) - range.setEnd(lastTextNode, lastTextNode.length) + setSelectionRange(el, cursorPosition, cursorPosition) - const sel = $elements.callNativeMethod(doc, 'getSelection') + return + } - $elements.callNativeMethod(sel, 'removeAllRanges') + $elements.callNativeMethod(doc, 'execCommand', 'selectAll', false, null) + const selection = doc.getSelection() - return $elements.callNativeMethod(sel, 'addRange', range) - } + // collapsing the range doesn't work on input/textareas, since the range contains more than the input element + // However, IE can always* set selection range, so only modern browsers (with the selection API) will need this + const direction = toStart ? 'backward' : 'forward' + + selection.modify('move', direction, 'line') } -//# TODO: think about renaming this -const replaceSelectionContents = function (el, key) { - if ($elements.isContentEditable(el)) { - return _replaceSelectionContentsContentEditable(el, key) - } +// if $elements.isInput(el) || $elements.isTextarea(el) +// length = $elements.getNativeProp(el, "value").length +// setSelectionRange(el, length, length) + +// else if $elements.isContentEditable(el) +// ## NOTE: can't use execCommand API here because we would have +// ## to selectAll and then collapse so we use the Selection API +// # doc = $document.getDocumentFromElement(el) +// # range = $elements.callNativeMethod(doc, "createRange") +// # hostContenteditable = _getHostContenteditable(el) +// # lastTextNode = _getInnerLastChild(hostContenteditable) - if ($elements.isInput(el) || $elements.isTextarea(el)) { +// # if lastTextNode.tagName is "BR" +// # lastTextNode = lastTextNode.parentNode + +// # range.setStart(lastTextNode, lastTextNode.length) +// # range.setEnd(lastTextNode, lastTextNode.length) + +// # sel = $elements.callNativeMethod(doc, "getSelection") +// # $elements.callNativeMethod(sel, "removeAllRanges") +// # $elements.callNativeMethod(sel, "addRange", range) + +// TODO: think about renaming this +const replaceSelectionContents = function (el: HTMLElement, key: string) { + if ($elements.canSetSelectionRangeElement(el)) { + // if ($elements.isRead) const { start, end } = getSelectionBounds(el) const value = $elements.getNativeProp(el, 'value') || '' const updatedValue = _insertSubstring(value, key, [start, end]) + if (value === updatedValue) { + return false + } + $elements.setNativeProp(el, 'value', updatedValue) - return setSelectionRange(el, start + key.length, start + key.length) + setSelectionRange(el, start + key.length, start + key.length) + + return true } + + const doc = $document.getDocumentFromElement(el) + + _replaceSelectionContentsWithExecCommand(doc, key) + + return false } const getCaretPosition = function (el) { const bounds = getSelectionBounds(el) - if ((bounds.start == null)) { - //# no selection + if (bounds.start == null) { + // no selection return null } @@ -500,19 +494,35 @@ const getCaretPosition = function (el) { return null } -const interceptSelect = function () { - if ($elements.isInput(this) && !$elements.canSetSelectionRangeElement(this)) { - setSelectionRange(this, 0, $elements.getNativeProp(this, 'value').length) +const _getActive = function (doc) { + // TODO: remove this state access + // eslint-disable-next-line + const activeEl = $elements.getNativeProp(doc, 'activeElement') + + return activeEl +} + +const focusCursor = function (el, doc) { + const elToFocus = $elements.getFirstFocusableEl($dom.wrap(el)).get(0) + + const prevFocused = _getActive(doc) + + elToFocus.focus() + + if ($elements.isInput(elToFocus) || $elements.isTextarea(elToFocus)) { + moveSelectionToEnd(doc) } - return $elements.callNativeMethod(this, 'select') + if ($elements.isContentEditable(elToFocus) && prevFocused !== elToFocus) { + moveSelectionToEnd(doc) + } } -//# Selection API implementation of insert newline. -//# Worth keeping around if we ever have to insert native -//# newlines if we are trying to support a browser or -//# environment without the document.execCommand('insertText', etc...) -//# +// Selection API implementation of insert newline. +// Worth keeping around if we ever have to insert native +// newlines if we are trying to support a browser or +// environment without the document.execCommand('insertText', etc...) +// // _insertNewlineIntoContentEditable = (el) -> // selection = _getSelectionByEl(el) // selection.deleteFromDocument() @@ -590,6 +600,7 @@ export { selectAll, deleteSelectionContents, moveSelectionToEnd, + moveSelectionToStart, getCaretPosition, getHostContenteditable, moveCursorLeft, @@ -600,5 +611,5 @@ export { moveCursorToLineEnd, replaceSelectionContents, isCollapsed, - interceptSelect, + focusCursor, } diff --git a/packages/driver/src/dom/visibility.js b/packages/driver/src/dom/visibility.js index 85fee6aec49a..6e1df389b89d 100644 --- a/packages/driver/src/dom/visibility.js +++ b/packages/driver/src/dom/visibility.js @@ -262,7 +262,7 @@ const elIsHiddenByAncestors = function ($el, $origEl = $el) { // in case there is no body // or if parent is the document which can // happen if we already have an element - if ($parent.is('body,html') || $document.isDocument($parent)) { + if (!$parent.length || $parent.is('body,html') || $document.isDocument($parent)) { return false } diff --git a/packages/driver/test/cypress/integration/commands/actions/click_spec.js b/packages/driver/test/cypress/integration/commands/actions/click_spec.js index 06bdf31146e2..2c79e337d5a9 100644 --- a/packages/driver/test/cypress/integration/commands/actions/click_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/click_spec.js @@ -552,9 +552,7 @@ describe('src/cy/commands/actions/click', () => { cy.get('#three-buttons button').click({ multiple: true }).then(() => { const calls = cy.timeout.getCalls() - const num = _.filter(calls, (call) => { - return _.isEqual(call.args, [50, true, 'click']) - }) + const num = _.filter(calls, (call) => _.isEqual(call.args, [50, true, 'click'])) expect(num.length).to.eq(count) }) @@ -1624,7 +1622,7 @@ describe('src/cy/commands/actions/click', () => { const input = cy.$$('input:first') input.get(0).addEventListener('focus', () => { - done('should not have recieved focused event') + done('should not have received focused event') }) input.get(0).addEventListener('mousedown', (e) => { @@ -1650,7 +1648,6 @@ describe('src/cy/commands/actions/click', () => { it('will not fire focus events when nothing can receive focus', () => { const onFocus = cy.stub() - const win = cy.state('window') const $body = cy.$$('body') const $div = cy.$$('#nested-find') @@ -1772,9 +1769,7 @@ describe('src/cy/commands/actions/click', () => { cy.on('fail', (err) => { const { lastLog, logs } = this - const logsArr = logs.map((log) => { - return log.get().consoleProps() - }) + const logsArr = logs.map((log) => log.get().consoleProps()) expect(logsArr).to.have.length(4) expect(lastLog.get('error')).to.eq(err) @@ -2157,9 +2152,7 @@ describe('src/cy/commands/actions/click', () => { }) it('#consoleProps groups MouseDown', () => { - cy.$$('input:first').mousedown(() => { - return false - }) + cy.$$('input:first').mousedown(_.stubFalse) cy.get('input:first').click().then(function () { const consoleProps = this.lastLog.invoke('consoleProps') @@ -2233,9 +2226,7 @@ describe('src/cy/commands/actions/click', () => { }) it('#consoleProps groups MouseUp', () => { - cy.$$('input:first').mouseup(() => { - return false - }) + cy.$$('input:first').mouseup(_.stubFalse) cy.get('input:first').click().then(function () { expect(this.lastLog.invoke('consoleProps').table[2]().data).to.containSubset([ @@ -2274,9 +2265,7 @@ describe('src/cy/commands/actions/click', () => { }) it('#consoleProps groups Click', () => { - cy.$$('input:first').click(() => { - return false - }) + cy.$$('input:first').click(_.stubFalse) cy.get('input:first').click().then(function () { expect(this.lastLog.invoke('consoleProps').table[2]().data).to.containSubset([ @@ -2381,9 +2370,7 @@ describe('src/cy/commands/actions/click', () => { }) it('#consoleProps groups have activated modifiers', () => { - cy.$$('input:first').click(() => { - return false - }) + cy.$$('input:first').click(_.stubFalse) cy.get('input:first').type('{ctrl}{shift}', { release: false }).click().then(function () { expect(this.lastLog.invoke('consoleProps').table[2]().data).to.containSubset([ @@ -2719,9 +2706,7 @@ describe('src/cy/commands/actions/click', () => { cy.get('#three-buttons button').dblclick().then(() => { const calls = cy.timeout.getCalls() - const num = _.filter(calls, (call) => { - return _.isEqual(call.args, [50, true, 'dblclick']) - }) + const num = _.filter(calls, (call) => _.isEqual(call.args, [50, true, 'dblclick'])) expect(num.length).to.eq(count) }) diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.js b/packages/driver/test/cypress/integration/commands/actions/type_spec.js index 500188d7579e..0fa74a8cf242 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.js @@ -156,15 +156,30 @@ describe('src/cy/commands/actions/type', () => { }) it('does not click when body is subject', () => { - let bodyClicked = false + const clicked = cy.stub() - cy.$$('body').on('click', () => { - bodyClicked = true - }) + cy.$$('body').on('click', clicked) cy.get('body').type('foo').then(() => { - expect(bodyClicked).to.be.false + expect(clicked).not.to.be.calledOnce + }) + }) + + it('can type into element that redirects focus', () => { + const $firstTabIndexEl = cy.$$('div[tabindex]:first') + + $firstTabIndexEl.on('focus', () => { + cy.$$('input:first').focus() + }) + + cy.get('div[tabindex]:first').type('foobar').then(($subject) => { + // should not have changed the subject + expect($subject.get(0)).to.eq($firstTabIndexEl.get(0)) }) + + cy.get('input:first') + .should('be.focused') + .should('have.value', 'foobar') }) describe('actionability', () => { @@ -173,14 +188,12 @@ describe('src/cy/commands/actions/type', () => { expect($txt).not.to.have.value('foo') - let clicked = false + const clicked = cy.stub() - $txt.on('click', () => { - clicked = true - }) + $txt.on('click', clicked) cy.get(':text:first').type('foo', { force: true }).then(($input) => { - expect(clicked).to.be.true + expect(clicked).to.be.calledOnce expect($input).to.have.value('foo') }) @@ -205,14 +218,12 @@ describe('src/cy/commands/actions/type', () => { }) .prependTo(cy.$$('body')) - let clicked = false + const clicked = cy.stub() - $input.on('click', () => { - clicked = true - }) + $input.on('click', clicked) cy.get('#input-covered-in-span').type('foo', { force: true }).then(($input) => { - expect(clicked).to.be.true + expect(clicked).to.be.calledOnce expect($input).to.have.value('foo') }) @@ -221,68 +232,62 @@ describe('src/cy/commands/actions/type', () => { it('waits until element becomes visible', () => { const $txt = cy.$$(':text:first').hide() - let retried = false + const retried = cy.stub() cy.on('command:retry', _.after(3, () => { $txt.show() - retried = true + retried() })) cy.get(':text:first').type('foo').then(() => { - expect(retried).to.be.true + expect(retried).to.be.called }) }) it('waits until element is no longer disabled', () => { const $txt = cy.$$(':text:first').prop('disabled', true) - let retried = false - let clicks = 0 + const retried = cy.stub() + const clicked = cy.stub() - $txt.on('click', () => { - clicks += 1 - }) + $txt.on('click', clicked) cy.on('command:retry', _.after(3, () => { $txt.prop('disabled', false) - retried = true + retried() })) cy.get(':text:first').type('foo').then(() => { - expect(clicks).to.eq(1) + expect(clicked).to.be.calledOnce - expect(retried).to.be.true + expect(retried).to.be.called }) }) it('waits until element is no longer readonly', () => { const $txt = cy.$$(':text:first').prop('readonly', true) - let retried = false - let clicks = 0 + const retried = cy.stub() + const clicked = cy.stub() - $txt.on('click', () => { - clicks += 1 - }) + $txt.on('click', clicked) cy.on('command:retry', _.after(3, () => { $txt.prop('readonly', false) - retried = true + retried() })) cy.get(':text:first').type('foo').then(() => { - expect(clicks).to.eq(1) + expect(clicked).to.be.calledOnce - expect(retried).to.be.true + expect(retried).to.be.called }) }) it('waits until element stops animating', () => { - let retries = 0 + const retried = cy.stub() - cy.on('command:retry', () => { - retries += 1 - }) + cy.on('command:retry', retried) cy.stub(cy, 'ensureElementIsNotAnimating') .throws(new Error('animating!')) @@ -292,7 +297,7 @@ describe('src/cy/commands/actions/type', () => { // - retry animation coords // - retry animation // - retry animation - expect(retries).to.eq(3) + expect(retried).to.be.calledThrice expect(cy.ensureElementIsNotAnimating).to.be.calledThrice }) @@ -545,9 +550,7 @@ describe('src/cy/commands/actions/type', () => { const $txt = cy.$$(':text:first') $txt.on('keydown', (e) => { - const obj = _.pick(e.originalEvent, 'altKey', 'bubbles', 'cancelable', 'charCode', 'ctrlKey', 'detail', 'keyCode', 'view', 'layerX', 'layerY', 'location', 'metaKey', 'pageX', 'pageY', 'repeat', 'shiftKey', 'type', 'which', 'key') - - expect(obj).to.deep.eq({ + expect(_.toPlainObject(e.originalEvent)).to.include({ altKey: false, bubbles: true, cancelable: true, @@ -555,17 +558,14 @@ describe('src/cy/commands/actions/type', () => { ctrlKey: false, detail: 0, key: 'a', + // has code property https://github.com/cypress-io/cypress/issues/3722 + code: 'KeyA', keyCode: 65, // deprecated but fired by chrome always uppercase in the ASCII table - layerX: 0, - layerY: 0, location: 0, metaKey: false, - pageX: 0, - pageY: 0, repeat: false, shiftKey: false, type: 'keydown', - view: cy.state('window'), which: 65, // deprecated but fired by chrome }) @@ -579,9 +579,7 @@ describe('src/cy/commands/actions/type', () => { const $txt = cy.$$(':text:first') $txt.on('keypress', (e) => { - const obj = _.pick(e.originalEvent, 'altKey', 'bubbles', 'cancelable', 'charCode', 'ctrlKey', 'detail', 'keyCode', 'view', 'layerX', 'layerY', 'location', 'metaKey', 'pageX', 'pageY', 'repeat', 'shiftKey', 'type', 'which', 'key') - - expect(obj).to.deep.eq({ + expect(_.toPlainObject(e.originalEvent)).to.include({ altKey: false, bubbles: true, cancelable: true, @@ -589,17 +587,13 @@ describe('src/cy/commands/actions/type', () => { ctrlKey: false, detail: 0, key: 'a', + code: 'KeyA', keyCode: 97, // deprecated - layerX: 0, - layerY: 0, location: 0, metaKey: false, - pageX: 0, - pageY: 0, repeat: false, shiftKey: false, type: 'keypress', - view: cy.state('window'), which: 97, // deprecated }) @@ -613,9 +607,7 @@ describe('src/cy/commands/actions/type', () => { const $txt = cy.$$(':text:first') $txt.on('keyup', (e) => { - const obj = _.pick(e.originalEvent, 'altKey', 'bubbles', 'cancelable', 'charCode', 'ctrlKey', 'detail', 'keyCode', 'view', 'layerX', 'layerY', 'location', 'metaKey', 'pageX', 'pageY', 'repeat', 'shiftKey', 'type', 'which', 'key') - - expect(obj).to.deep.eq({ + expect(_.toPlainObject(e.originalEvent)).to.include({ altKey: false, bubbles: true, cancelable: true, @@ -623,13 +615,10 @@ describe('src/cy/commands/actions/type', () => { ctrlKey: false, detail: 0, key: 'a', + code: 'KeyA', keyCode: 65, // deprecated but fired by chrome always uppercase in the ASCII table - layerX: 0, - layerY: 0, location: 0, metaKey: false, - pageX: 0, - pageY: 0, repeat: false, shiftKey: false, type: 'keyup', @@ -652,14 +641,8 @@ describe('src/cy/commands/actions/type', () => { expect(obj).to.deep.eq({ bubbles: true, cancelable: true, - charCode: 0, data: 'a', detail: 0, - keyCode: 0, - layerX: 0, - layerY: 0, - pageX: 0, - pageY: 0, type: 'textInput', view: cy.state('window'), which: 0, @@ -694,47 +677,47 @@ describe('src/cy/commands/actions/type', () => { it('fires events for each key stroke') it('does fire input event when value changes', () => { - let fired = false + const onInput = cy.stub() - cy.$$(':text:first').on('input', () => { - fired = true - }) + cy.$$(':text:first').on('input', onInput) - fired = false cy.get(':text:first') .invoke('val', 'bar') .type('{selectAll}{rightarrow}{backspace}') .then(() => { - expect(fired).to.eq(true) + expect(onInput).to.be.calledOnce + }) + .then(() => { + onInput.reset() }) - fired = false cy.get(':text:first') .invoke('val', 'bar') .type('{selectAll}{leftarrow}{del}') .then(() => { - expect(fired).to.eq(true) + expect(onInput).to.be.calledOnce }) - - cy.$$('[contenteditable]:first').on('input', () => { - fired = true + .then(() => { + onInput.reset() }) - fired = false + cy.$$('[contenteditable]:first').on('input', onInput) + cy.get('[contenteditable]:first') .invoke('html', 'foobar') .type('{selectAll}{rightarrow}{backspace}') .then(() => { - expect(fired).to.eq(true) + expect(onInput).to.be.calledOnce + }) + .then(() => { + onInput.reset() }) - - fired = false cy.get('[contenteditable]:first') .invoke('html', 'foobar') .type('{selectAll}{leftarrow}{del}') .then(() => { - expect(fired).to.eq(true) + expect(onInput).to.be.calledOnce }) }) @@ -745,7 +728,6 @@ describe('src/cy/commands/actions/type', () => { fired = true }) - fired = false cy.get(':text:first') .invoke('val', 'bar') .type('{selectAll}{rightarrow}{del}') @@ -753,7 +735,6 @@ describe('src/cy/commands/actions/type', () => { expect(fired).to.eq(false) }) - fired = false cy.get(':text:first') .invoke('val', 'bar') .type('{selectAll}{leftarrow}{backspace}') @@ -765,7 +746,6 @@ describe('src/cy/commands/actions/type', () => { fired = true }) - fired = false cy.get('textarea:first') .invoke('val', 'bar') .type('{selectAll}{rightarrow}{del}') @@ -773,7 +753,6 @@ describe('src/cy/commands/actions/type', () => { expect(fired).to.eq(false) }) - fired = false cy.get('textarea:first') .invoke('val', 'bar') .type('{selectAll}{leftarrow}{backspace}') @@ -785,16 +764,13 @@ describe('src/cy/commands/actions/type', () => { fired = true }) - fired = false cy.get('[contenteditable]:first') .invoke('html', 'foobar') - .type('{selectAll}{rightarrow}{del}') - .then(() => { + .type('{movetoend}') + .then(($el) => { expect(fired).to.eq(false) }) - fired = false - cy.get('[contenteditable]:first') .invoke('html', 'foobar') .type('{selectAll}{leftarrow}{backspace}') @@ -829,6 +805,37 @@ describe('src/cy/commands/actions/type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/4587 + it('borrows property getter from outer frame for input', () => { + const $input = cy.$$(':text:first') + + $input.attr('maxlength', 5) + Object.defineProperty($input[0], 'maxLength', { + value: 2, + }) + + cy.get(':text:first') + .type('1234567890') + .then((input) => { + expect(input).to.have.value('12345') + }) + }) + + it('borrows property getter from outer frame for textarea', () => { + const $input = cy.$$('textarea:first') + + $input.attr('maxlength', 5) + Object.defineProperty($input[0], 'maxLength', { + value: 2, + }) + + cy.get('textarea:first') + .type('1234567890') + .then((input) => { + expect(input).to.have.value('12345') + }) + }) + it('handles special characters', () => { const $input = cy.$$(':text:first') @@ -1161,17 +1168,54 @@ describe('src/cy/commands/actions/type', () => { .should('have.value', '-123.12') }) - it('type=number blurs consistently', () => { - let blurred = 0 + it('can type {del}', () => { + cy.get('#number-with-value') + .type('{selectAll}{del}') + .should('have.value', '') + }) + + it('can type {selectAll}{del}', () => { + const sentInput = cy.stub() + + cy.get('#number-with-value') + .then(($el) => $el.on('input', sentInput)) + .type('{selectAll}{del}') + .should('have.value', '') + .then(() => { + expect(sentInput).to.be.calledOnce + }) + }) + + it('can type {selectAll}{del} without sending input event', () => { + const sentInput = cy.stub() - cy.$$('#number-without-value').blur(() => { - return blurred++ + cy.get('#number-without-value') + .then(($el) => $el.on('input', sentInput)) + .type('{selectAll}{del}') + .should('have.value', '') + .then(() => { + expect(sentInput).not.to.be.called }) + }) + + // https://github.com/cypress-io/cypress/issues/4767 + it('can type negative numbers with currently active selection', () => { + cy.get('#number-without-value') + .type('999') + .type('{selectall}') + .type('-123.12') + .should('have.value', '-123.12') + }) + + it('type=number blurs consistently', () => { + const blurred = cy.stub() + + cy.$$('#number-without-value').blur(blurred) cy.get('#number-without-value') .type('200').blur() .then(() => { - expect(blurred).to.eq(1) + expect(blurred).to.be.calledOnce }) }) }) @@ -1216,16 +1260,14 @@ describe('src/cy/commands/actions/type', () => { }) it('type=email blurs consistently', () => { - let blurred = 0 + const blurred = cy.stub() - cy.$$('#email-without-value').blur(() => { - blurred++ - }) + cy.$$('#email-without-value').blur(blurred) cy.get('#email-without-value') .type('foo@bar.com').blur() .then(() => { - expect(blurred).to.eq(1) + expect(blurred).to.be.calledOnce }) }) }) @@ -1307,6 +1349,89 @@ describe('src/cy/commands/actions/type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/3661 + describe('various focusable elements', () => { + it('') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') - + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) @@ -4884,7 +5039,7 @@ https://on.cypress.io/type`) expect(err.message).to.include('cy.clear() failed because it requires a valid clearable element.') expect(err.message).to.include('The element cleared was:') expect(err.message).to.include('') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) diff --git a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee index 5fadf4edfed6..22be0c5a0c18 100644 --- a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee @@ -632,6 +632,7 @@ describe "src/cy/commands/waiting", -> .wait("@getUsers").then (xhr) -> expect(xhr.url).to.include "/users?num=4" expect(xhr.responseBody).to.deep.eq resp + null describe "errors", -> describe "invalid 1st argument", -> @@ -915,4 +916,4 @@ describe "src/cy/commands/waiting", -> # Command: "wait" # "Waited For": _.str.clean(fn.toString()) # Retried: "3 times" - # } \ No newline at end of file + # } diff --git a/packages/driver/test/cypress/integration/dom/elements_spec.js b/packages/driver/test/cypress/integration/dom/elements_spec.js index 7ac247e052e3..70575505a850 100644 --- a/packages/driver/test/cypress/integration/dom/elements_spec.js +++ b/packages/driver/test/cypress/integration/dom/elements_spec.js @@ -99,7 +99,7 @@ describe('src/dom/elements', () => { }) }) - context('.isType', () => { + context('.isInputType', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) @@ -107,15 +107,15 @@ describe('src/dom/elements', () => { it('when type is a string', () => { const $el = $('input[type="number"]') - expect(Cypress.dom.isType($el, 'number')).to.be.true - expect(Cypress.dom.isType($el, 'text')).to.be.false + expect(Cypress.dom.isInputType($el, 'number')).to.be.true + expect(Cypress.dom.isInputType($el, 'text')).to.be.false }) it('when type is an array', () => { const $el = $('input[type="number"]') - expect(Cypress.dom.isType($el, ['number', 'text', 'email'])).to.be.true - expect(Cypress.dom.isType($el, ['text', 'email'])).to.be.false + expect(Cypress.dom.isInputType($el, ['number', 'text', 'email'])).to.be.true + expect(Cypress.dom.isInputType($el, ['text', 'email'])).to.be.false }) }) }) diff --git a/packages/driver/test/cypress/integration/e2e/keyboard_spec.coffee b/packages/driver/test/cypress/integration/e2e/keyboard_spec.coffee index 23c1695d40e0..81bd17735c02 100644 --- a/packages/driver/test/cypress/integration/e2e/keyboard_spec.coffee +++ b/packages/driver/test/cypress/integration/e2e/keyboard_spec.coffee @@ -27,7 +27,11 @@ describe "keyboard", -> characters = [ ['.', 46, 190], ['/', 47, 191], - ['{enter}', 13, 13] + ['{enter}', 13, 13], + ['*', 42, 56], + ['+', 43, 187], + ['-', 45, 189], + ] characters.forEach ([char, asciiCode, keyCode]) -> diff --git a/packages/server/test/unit/project_spec.coffee b/packages/server/test/unit/project_spec.coffee index 1602b7f8da76..92ce844d07cd 100644 --- a/packages/server/test/unit/project_spec.coffee +++ b/packages/server/test/unit/project_spec.coffee @@ -211,10 +211,12 @@ describe "lib/project", -> expect(@project.saveState).to.be.calledWith({ autoScrollingEnabled: false}) expect(config.state).to.eql({ autoScrollingEnabled: false }) + # TODO: skip this for now it.skip "watches cypress.json", -> @server.open().bind(@).then -> expect(Watchers::watch).to.be.calledWith("/Users/brian/app/cypress.json") - + + # TODO: skip this for now it.skip "passes watchers to Socket.startListening", -> options = {} @@ -222,6 +224,7 @@ describe "lib/project", -> startListening = Socket::startListening expect(startListening.getCall(0).args[0]).to.be.instanceof(Watchers) expect(startListening.getCall(0).args[1]).to.eq(options) + null context "#close", -> beforeEach ->