@revoloo/cypress6
Version:
Cypress.io end to end testing tool
1,316 lines (1,066 loc) • 33.8 kB
text/typescript
import Promise from 'bluebird'
import Debug from 'debug'
import _ from 'lodash'
import moment from 'moment'
import $errUtils from '../cypress/error_utils'
import { USKeyboard } from '../cypress/UsKeyboardLayout'
import * as $dom from '../dom'
import * as $document from '../dom/document'
import * as $elements from '../dom/elements'
// eslint-disable-next-line no-duplicate-imports
import { HTMLTextLikeElement, HTMLTextLikeInputElement } from '../dom/elements'
import * as $selection from '../dom/selection'
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<T> {
<K extends keyof T>(arg: K): T[K] | undefined
<K extends keyof T>(arg: K, arg2: T[K] | null): void
}
export type State = ProxyState<KeyboardState>
interface KeyDetailsPartial extends Partial<KeyDetails> {
key: string
}
type SimulatedDefault = (
el: HTMLElement,
key: KeyDetails,
options: typeOptions
) => void
type KeyInfo = KeyDetails | ShortcutDetails
interface KeyDetails {
type: 'key'
key: string
text: string | null
code: string
keyCode: number
location: number
shiftKey?: string
shiftText?: string
shiftKeyCode?: number
simulatedDefault?: SimulatedDefault
simulatedDefaultOnly?: boolean
originalSequence?: string
events: {
[key in KeyEventType]?: boolean;
}
}
interface ShortcutDetails {
type: 'shortcut'
modifiers: KeyDetails[]
key: KeyDetails
originalSequence: string
}
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 numberRe = /^-?(\d+|\d+\.\d+|\.\d+)([eE][-+]?\d+)?$/i
const charsBetweenCurlyBracesRe = /({.+?})/
const isValidNumberInputChar = /[-+eE\d\.]/
const INITIAL_MODIFIERS = {
alt: false,
ctrl: false,
meta: false,
shift: false,
}
/**
* @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'
| 'beforeinput'
const toModifiersEventOptions = (modifiers: KeyboardModifiers) => {
return {
altKey: modifiers.alt,
ctrlKey: modifiers.ctrl,
metaKey: modifiers.meta,
shiftKey: modifiers.shift,
}
}
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()
}
const modifiersToString = (modifiers: KeyboardModifiers) => {
return _.keys(_.pickBy(modifiers, (val) => {
return val
})).join(', ')
}
const joinKeyArrayToString = (keyArr: KeyInfo[]) => {
return _.map(keyArr, (key) => {
if (key.type === 'key') {
if (key.text) return key.key
return `{${key.key}}`
}
return `{${key.originalSequence}}`
}).join('')
}
type modifierKeyDetails = KeyDetails & {
key: keyof typeof keyToModifierMap
}
const isModifier = (details: KeyInfo): details is modifierKeyDetails => {
return details.type === 'key' && !!keyToModifierMap[details.key]
}
const getFormattedKeyString = (details: KeyDetails) => {
let foundKeyString = _.findKey(keyboardMappings, { key: details.key })
if (foundKeyString) {
return `{${details.originalSequence}}`
}
foundKeyString = keyToModifierMap[details.key]
if (foundKeyString) {
return `{${details.originalSequence}}`
}
return details.originalSequence
}
const countNumIndividualKeyStrokes = (keys: KeyInfo[]) => {
return _.countBy(keys, isModifier)['false']
}
const findKeyDetailsOrLowercase = (key: string): KeyDetailsPartial => {
const keymap = getKeymap()
const foundKey = keymap[key]
if (foundKey) return foundKey
return _.mapKeys(keymap, (val, key) => {
return _.toLower(key)
})[_.toLower(key)]
}
const getTextLength = (str) => _.toArray(str).length
const getKeyDetails = (onKeyNotFound) => {
return (key: string): KeyDetails | ShortcutDetails => {
let foundKey: KeyDetailsPartial
if (getTextLength(key) === 1) {
foundKey = USKeyboard[key] || { key }
} else {
foundKey = findKeyDetailsOrLowercase(key)
}
if (foundKey) {
const details = _.defaults({}, foundKey, {
type: 'key',
key: '',
keyCode: 0,
code: '',
text: null,
location: 0,
events: {},
}) as KeyDetails
if (getTextLength(details.key) === 1) {
details.text = details.key
}
details.type = 'key'
details.originalSequence = key
return details
}
if (key.includes('+')) {
if (key.endsWith('++')) {
key = key.replace('++', '+plus')
}
const keys = key.split('+')
let lastKey = _.last(keys)
if (lastKey === 'plus') {
keys[keys.length - 1] = '+'
lastKey = '+'
}
if (!lastKey) {
return onKeyNotFound(key, _.keys(getKeymap()).join(', '))
}
const keyWithModifiers = getKeyDetails(onKeyNotFound)(lastKey) as KeyDetails
let hasModifierBesidesShift = false
const modifiers = keys.slice(0, -1)
.map((m) => {
if (!Object.keys(modifierChars).includes(m)) {
$errUtils.throwErrByPath('type.not_a_modifier', {
args: {
key: m,
},
})
}
if (m !== 'shift') {
hasModifierBesidesShift = true
}
return getKeyDetails(onKeyNotFound)(m)
}) as KeyDetails[]
const details: ShortcutDetails = {
type: 'shortcut',
modifiers,
key: keyWithModifiers,
originalSequence: key,
}
// if we are going to type {ctrl+b}, the 'b' shouldn't be input as text
// normally we don't bypass text input but for shortcuts it's definitely what the user wants
// since the modifiers only apply to this single key.
if (hasModifierBesidesShift) {
details.key.text = null
}
return details
}
onKeyNotFound(key, _.keys(getKeymap()).join(', '))
throw new Error('this can never happen')
}
}
/**
* @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, options: typeOptions) => {
if (!key.text) return false
const bounds = $selection.getSelectionBounds(el)
const noneSelected = bounds.start === bounds.end
if ($elements.isInput(el) || $elements.isTextarea(el)) {
if ($elements.isReadOnlyInputOrTextarea(el) && !options.force) {
return false
}
const isNumberInputType = $elements.isInput(el) && $elements.isInputType(el, 'number')
if (isNumberInputType) {
const needsValue = options.prevValue || ''
const needsValueLength = (needsValue && needsValue.length) || 0
const curVal = $elements.getNativeProp(el, 'value')
const bounds = $selection.getSelectionBounds(el)
// We need to see if the number we're about to type is a valid number, since setting a number input
// to an invalid number will not set the value and possibly throw a warning in the console
const potentialValue = $selection.insertSubstring(curVal + needsValue, key.text, [bounds.start + needsValueLength, bounds.end + needsValueLength])
if (!(numberRe.test(potentialValue))) {
debug('skipping inserting value since number input would be invalid', key.text, potentialValue)
// when typing in a number input, only certain allowed chars will insert text
if (!key.text.match(isValidNumberInputChar)) {
// https://github.com/cypress-io/cypress/issues/6055
// Should not remove old valid values when a new one is not a valid number char, just dismiss it with return
return
}
options.prevValue = needsValue + key.text
return
}
key.text = (options.prevValue || '') + key.text
options.prevValue = undefined
}
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: KeyInfo[],
currentIndex: number,
onFail: Function,
skipCheckUntilIndex: number | undefined,
force: boolean,
) => {
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
// use 'type' attribute instead of prop since browsers without
// support for attribute input type will have type prop of 'text'
if ($elements.isInput(el)) {
isDate = $elements.isAttrType(el, 'date')
isTime = $elements.isAttrType(el, 'time')
isMonth = $elements.isAttrType(el, 'month')
isWeek = $elements.isAttrType(el, 'week')
isDateTime =
$elements.isAttrType(el, 'datetime') || $elements.isAttrType(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 {}
}
// throw error if element, which is normally typeable, is disabled for some reason
// don't throw if force: true
if (!isFocusable && isTextLike && !force) {
const node = $dom.stringify($el)
$errUtils.throwErrByPath('type.not_actionable_textlike', {
onFail,
args: { node },
})
}
// throw error if element cannot receive keyboard events under any conditions
if (!isFocusable && !isTextLike) {
const node = $dom.stringify($el)
$errUtils.throwErrByPath('type.not_on_typeable_element', {
onFail,
args: { node },
})
}
if (numElements > 1) {
$errUtils.throwErrByPath('type.multiple_elements', {
onFail,
args: { num: numElements },
})
}
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 }
}
$errUtils.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 }
}
$errUtils.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 }
}
$errUtils.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 }
}
$errUtils.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 }
}
$errUtils.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 input element, Enter key does not insert text
if (!$elements.isInput(el)) {
$selection.replaceSelectionContents(el, '\n')
}
options.onEnterPressed && 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) => {
$selection.selectAll(el)
},
simulatedDefaultOnly: true,
},
moveToStart: {
key: 'moveToStart',
simulatedDefault: (el) => {
$selection.moveSelectionToStart(el)
},
simulatedDefaultOnly: true,
},
moveToEnd: {
key: 'moveToEnd',
simulatedDefault: (el) => {
$selection.moveSelectionToEnd(el)
},
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,
}
const keyToModifierMap = {
Alt: 'alt',
Control: 'ctrl',
Meta: 'meta',
Shift: 'shift',
}
export interface typeOptions {
id: string
$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
prevValue?: string
}
export class Keyboard {
private SUPPORTS_BEFOREINPUT_EVENT
constructor (private Cypress, private state: State) {
this.SUPPORTS_BEFOREINPUT_EVENT = Cypress.isBrowser({ family: 'chromium' })
}
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)
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)
let _skipCheckUntilIndex: number | undefined = 0
const typeKeyFns = _.map(
keyDetailsArr,
(key: KeyInfo, currentKeyIndex: number) => {
return () => {
const activeEl = this.getActiveEl(options)
if (key.type === 'shortcut') {
this.simulateShortcut(activeEl, key, options)
return null
}
debug('typing key:', key.key)
_skipCheckUntilIndex = _skipCheckUntilIndex && _skipCheckUntilIndex - 1
if (!_skipCheckUntilIndex) {
const { skipCheckUntilIndex, isClearChars } = validateTyping(
activeEl,
keyDetailsArr,
currentKeyIndex,
options.onFail,
_skipCheckUntilIndex,
options.force,
)
_skipCheckUntilIndex = skipCheckUntilIndex
if (
_skipCheckUntilIndex
&& $elements.isNeedSingleValueChangeInputElement(activeEl)
) {
const originalText = $elements.getNativeProp(activeEl, 'value')
debug('skip validate until:', _skipCheckUntilIndex)
const keysToType = keyDetailsArr.slice(currentKeyIndex, currentKeyIndex + _skipCheckUntilIndex)
_.each(keysToType, (key) => {
// singleValueChange inputs must have their value set once at the end
// performing the simulatedDefault for a key would try to insert text on each character
// we still send all the events as normal, however
if (key.type === 'key') {
key.simulatedDefault = _.noop
}
})
const lastKeyToType = _.last(keysToType)!
if (lastKeyToType.type === 'key') {
lastKeyToType.simulatedDefault = () => {
options.onValueChange(originalText, activeEl)
const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType)
debug('setting element value', valToSet, activeEl)
return $elements.setNativeProp(
activeEl as $elements.HTMLTextLikeInputElement,
'value',
valToSet,
)
}
}
}
} else {
debug('skipping validation due to *skipCheckUntilIndex*', _skipCheckUntilIndex)
}
// simulatedDefaultOnly keys will not send any events, and cannot be canceled
if (key.simulatedDefaultOnly) {
key.simulatedDefault!(activeEl as HTMLTextLikeElement, key, options)
return null
}
this.typeSimulatedKey(activeEl, key, options)
return null
}
},
)
// we will only press each modifier once, so only find unique modifiers
const modifierKeys = _
.chain(keyDetailsArr)
.filter(isModifier)
.uniqBy('key')
.value()
return Promise
.each(typeKeyFns, (fn) => {
return Promise
.try(fn)
.delay(options.delay)
})
.then(() => {
if (options.release !== false) {
return Promise.map(modifierKeys, (key) => {
options.id = _.uniqueId('char')
return this.simulatedKeyup(this.getActiveEl(options), key, options)
})
}
return []
})
.then(options.onAfterType)
}
fireSimulatedEvent (
el: HTMLElement,
eventType: KeyEventType,
keyDetails: KeyDetails,
opts: typeOptions,
) {
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: Nullable<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
let inputType: string | undefined
switch (eventType) {
case 'keydown':
case 'keyup': {
keyCode = keyDetails.keyCode
which = keyDetails.keyCode
key = keyDetails.key
charCode = 0
break
}
case 'keypress': {
const charCodeAt = text!.charCodeAt(0)
charCode = charCodeAt
keyCode = charCodeAt
which = charCodeAt
key = keyDetails.key
break
}
case 'textInput': // lowercase in IE11
eventConstructor = 'TextEvent'
addModifiers = false
charCode = 0
keyCode = 0
which = 0
location = undefined
data = text === '\r' ? '↵' : text
break
case 'beforeinput':
eventConstructor = 'InputEvent'
addModifiers = false
data = text === '\r' ? null : text
code = undefined
location = undefined
cancelable = true
inputType = this.getInputType(keyDetails.code, $elements.isContentEditable(el))
break
case 'input':
eventConstructor = 'InputEvent'
addModifiers = false
data = text === '\r' ? null : text
location = undefined
cancelable = false
break
default: {
throw new Error(`Invalid event: ${eventType}`)
}
}
let eventOptions: EventInit & {
view?: Window
data?: string
repeat?: boolean
} = {}
if (addModifiers) {
const modifierEventOptions = toModifiersEventOptions(this.getActiveModifiers())
eventOptions = {
...eventOptions,
...modifierEventOptions,
repeat: false,
}
}
eventOptions = {
...eventOptions,
..._.omitBy(
{
bubbles: true,
cancelable,
key,
code,
charCode,
location,
keyCode,
which,
data,
detail: 0,
view: win,
inputType,
},
_.isUndefined,
),
}
let event: Event
debug('event options:', eventType, eventOptions)
if (eventConstructor === 'TextEvent' && win[eventConstructor]) {
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 {
let constructor = win[eventConstructor]
// When event constructor doesn't exist, fallback to KeyboardEvent.
// It's necessary because Firefox doesn't support InputEvent.
if (typeof constructor !== 'function') {
constructor = win['KeyboardEvent']
}
event = new constructor(eventType, eventOptions)
_.extend(event, eventOptions)
}
const dispatched = el.dispatchEvent(event)
debug(`dispatched [${eventType}] on ${el}`)
const formattedKeyString = getFormattedKeyString(keyDetails)
options.onEvent(options.id, formattedKeyString, event, dispatched)
return dispatched
}
getInputType (code, isContentEditable) {
// TODO: we DO set inputType for the following but DO NOT perform the correct default action
// e.g: we don't delete the entire word with `{ctrl}{del}` but send correct inputType:
// - deleteWordForward
// - deleteWordBackward
// - deleteHardLineForward
// - deleteHardLineBackward
//
// TODO: we do NOT set the following input types at all, since we don't yet support copy/paste actions
// e.g. we dont actually paste clipboard contents when typing '{ctrl}v':
// - insertFromPaste
// - deleteByCut
// - historyUndo
// - historyRedo
//
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event
const { shift, ctrl } = this.getActiveModifiers()
if (code === 'Enter') {
return isContentEditable ? 'insertParagraph' : 'insertLineBreak'
}
if (code === 'Backspace') {
if (shift && ctrl) {
return 'deleteHardLineBackward'
}
if (ctrl) {
return 'deleteWordBackward'
}
return 'deleteContentBackward'
}
if (code === 'Delete') {
if (shift && ctrl) {
return 'deleteHardLineForward'
}
if (ctrl) {
return 'deleteWordForward'
}
return 'deleteContentForward'
}
return 'insertText'
}
getActiveModifiers () {
return _.clone(this.state('keyboardModifiers')) || _.clone(INITIAL_MODIFIERS)
}
getModifierKeyDetails (key: KeyDetails) {
const modifiers = this.getActiveModifiers()
const details = { ...key, modifiers: getModifiersValue(modifiers) }
if (modifiers.shift && details.shiftKey) {
details.key = details.shiftKey
}
if (modifiers.shift && details.shiftKeyCode) {
details.keyCode = details.shiftKeyCode
}
if (modifiers.shift && details.shiftText) {
details.text = details.shiftText
}
// TODO: Re-think skipping text insert if non-shift modifers
// @see https://github.com/cypress-io/cypress/issues/5622
// if (hasModifierBesidesShift(modifiers)) {
// details.text = ''
// }
return details
}
flagModifier (key: modifierKeyDetails, setTo = true) {
debug('handleModifier', key.key)
const modifier = keyToModifierMap[key.key]
// do nothing if already activated
if (Boolean(this.getActiveModifiers()[modifier]) === setTo) {
return false
}
const _activeModifiers = this.getActiveModifiers()
_activeModifiers[modifier] = setTo
this.state('keyboardModifiers', _activeModifiers)
return true
}
simulatedKeydown (el: HTMLElement, _key: KeyDetails, options: typeOptions) {
if (isModifier(_key)) {
const didFlag = this.flagModifier(_key)
if (!didFlag) {
// we've already pressed this modifier, so ignore it and don't fire keydown or keyup
_key.events.keydown = false
}
// don't fire keyup for modifier keys, this will happen after all other keys are typed
_key.events.keyup = false
}
const key = this.getModifierKeyDetails(_key)
if (!key.text) {
key.events.keypress = false
key.events.textInput = false
if (key.key !== 'Backspace' && key.key !== 'Delete') {
key.events.input = false
key.events.beforeinput = false
}
}
let elToType
options.id = _.uniqueId('char')
debug(
'typeSimulatedKey options:',
_.pick(options, ['keydown', 'keypress', 'textInput', 'input', 'id']),
)
if (
shouldIgnoreEvent('keydown', key.events) ||
this.fireSimulatedEvent(el, 'keydown', key, options)
) {
elToType = this.getActiveEl(options)
if (key.key === 'Enter' && $elements.isInput(elToType)) {
key.events.textInput = false
key.events.input = false
}
if ($elements.isContentEditable(elToType)) {
key.events.input = false
} else if ($elements.isReadOnlyInputOrTextarea(elToType)) {
key.events.textInput = false
}
if (
shouldIgnoreEvent('keypress', key.events) ||
this.fireSimulatedEvent(elToType, 'keypress', key, options)
) {
if (
!this.SUPPORTS_BEFOREINPUT_EVENT ||
shouldIgnoreEvent('beforeinput', key.events) ||
this.fireSimulatedEvent(elToType, 'beforeinput', key, options)
) {
if (
shouldIgnoreEvent('textInput', key.events) ||
this.fireSimulatedEvent(elToType, 'textInput', key, options)
) {
return this.performSimulatedDefault(elToType, key, options)
}
}
}
}
}
typeSimulatedKey (el: HTMLElement, key: KeyDetails, options) {
debug('typeSimulatedKey', key.key, el)
_.defaults(options, {
prevText: null,
})
const isFocusable = $elements.isFocusable($dom.wrap(el))
const isTextLike = $elements.isTextLike(el)
const isTypeableButNotTextLike = !isTextLike && isFocusable
if (isTypeableButNotTextLike) {
key.events.input = false
key.events.textInput = false
}
this.simulatedKeydown(el, key, options)
const elToKeyup = this.getActiveEl(options)
this.simulatedKeyup(elToKeyup, key, options)
}
simulateShortcut (el: HTMLElement, key: ShortcutDetails, options) {
key.modifiers.forEach((key) => {
this.simulatedKeydown(el, key, options)
})
this.simulatedKeydown(el, key.key, options)
this.simulatedKeyup(el, key.key, options)
options.id = _.uniqueId('char')
const elToKeyup = this.getActiveEl(options)
key.modifiers.reverse().forEach((key) => {
delete key.events.keyup
options.id = _.uniqueId('char')
this.simulatedKeyup(elToKeyup, key, options)
})
}
simulatedKeyup (el: HTMLElement, _key: KeyDetails, options: typeOptions) {
if (shouldIgnoreEvent('keyup', _key.events)) {
debug('simulatedKeyup: ignoring event')
delete _key.events.keyup
return
}
if (isModifier(_key)) {
this.flagModifier(_key, false)
}
const key = this.getModifierKeyDetails(_key)
this.fireSimulatedEvent(el, 'keyup', key, options)
}
getSimulatedDefaultForKey (key: KeyDetails, options) {
debug('getSimulatedDefaultForKey', key.key)
if (key.simulatedDefault) return key.simulatedDefault
if (simulatedDefaultKeyMap[key.key]) {
return simulatedDefaultKeyMap[key.key]
}
return (el: HTMLElement) => {
if (!shouldUpdateValue(el, key, options)) {
debug('skip typing key', false)
key.events.input = false
return
}
// noop if not in a text-editable
const ret = $selection.replaceSelectionContents(el, key.text)
debug('replaceSelectionContents:', key.text, ret)
}
}
getActiveEl (options) {
const el = options.$el.get(0)
if (options.force) {
return el
}
const doc = $document.getDocumentFromElement(el)
// If focus has changed to a new element, use the new element
// however, if the new element is the body (aka the current element was blurred) continue with the same element.
// this is to prevent strange edge cases where an element loses focus due to framework rerender or page load.
// https://github.com/cypress-io/cypress/issues/5480
options.targetEl = $elements.getActiveElByDocument(options.$el) || options.targetEl || doc.body
return options.targetEl
}
performSimulatedDefault (el: HTMLElement, key: KeyDetails, options: any) {
debug('performSimulatedDefault', key.key)
const simulatedDefault = this.getSimulatedDefaultForKey(key, options)
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)
}
debug({ key })
shouldIgnoreEvent('input', key.events) ||
this.fireSimulatedEvent(el, 'input', key, options)
return
}
return simulatedDefault(el as HTMLTextLikeElement, key, options)
}
}
const create = (Cypress, state) => {
return new Keyboard(Cypress, state)
}
export {
create,
getKeymap,
modifiersToString,
toModifiersEventOptions,
fromModifierEventOptions,
}