Skip to content

Commit

Permalink
feat: Support shortcut syntax in cy.type() (#8499)
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Kucera <[email protected]>
  • Loading branch information
sainthkh and kuceb authored Nov 9, 2020
1 parent 82d2968 commit 849f382
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 22 deletions.
71 changes: 71 additions & 0 deletions packages/driver/cypress/integration/commands/actions/type_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2372,6 +2372,77 @@ describe('src/cy/commands/actions/type - #type', () => {
})
})

// https://github.com/cypress-io/cypress/issues/5694
describe('shortcuts', () => {
beforeEach(function () {
cy.visit('fixtures/dom.html')
cy.on('log:added', (attrs, log) => {
this.lastLog = log
})
})

it('releases modfier keys at the end of the shortcut sequence', () => {
cy.get(':text:first').type('h{ctrl+alt++}i')
.then(function ($input) {
const table = this.lastLog.invoke('consoleProps').table[2]()

// eslint-disable-next-line
console.table(table.data, table.columns)

const beforeinput = Cypress.isBrowser('firefox') ? '' : ' beforeinput,'

expect(table.name).to.eq('Keyboard Events')
const expectedTable = {
1: { 'Details': '{ code: KeyH, which: 72 }', Typed: 'h', 'Events Fired': `keydown, keypress,${beforeinput} textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] },
2: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keydown', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
3: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
4: { 'Details': '{ code: Equal, which: 187 }', Typed: '+', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
5: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keyup', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
6: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] },
7: { 'Details': '{ code: KeyI, which: 73 }', Typed: 'i', 'Events Fired': `keydown, keypress,${beforeinput} textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] },
}

// uncomment for debugging
// _.each(table.data, (v, i) => expect(v).containSubset(expectedTable[i]))
expect(table.data).to.deep.eq(expectedTable)
})
})

it('can type a shortcut with special characters', () => {
// NOTE: the default actions we implement will NOT be taken into account with modifiers
// e.g. we do not the delete the entire word with ctrl+backspace
// this matches the same behavior as cy.type('{ctrl}{backspace}')
// TODO: maybe change this in the future, it's just more work
cy.get(':text:first').type('foo{ctrl+backspace}bar')
.should('have.value', 'fobar')
})

it('does not input text when non-shift modifier', () => {
// NOTE: in this case the modifier DOES change the default action (when modifier other than Shift is applied, do not insert text)
// since most users want to test a user issuing a shortcut, and it's simple for us to implement
cy.get(':text:first').type('{ctrl+b}hi')
.should('have.value', 'hi')
})

it('throws an error when a wrong modifier is given', () => {
cy.on('fail', (err) => {
expect(err.message).to.eq('`asdf` is not a modifier.')
})

cy.get(':text:first').type('{asdf+x}hi')
})

it('throws an error when shortcut is missing key', () => {
cy.on('fail', (err) => {
expect(err.message).to.contain('{ctrl+}')
expect(err.message).to.contain('is not recognized')
expect(err.message).to.contain('alt, option, ctrl')
})

cy.get(':text:first').type('{ctrl+}hi')
})
})

describe('case-insensitivity', () => {
it('special chars are case-insensitive', () => {
cy.get(':text:first').invoke('val', 'bar').type('{leftarrow}{DeL}').then(($input) => {
Expand Down
146 changes: 124 additions & 22 deletions packages/driver/src/cy/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ type SimulatedDefault = (
options: typeOptions
) => void

type KeyInfo = KeyDetails | ShortcutDetails

interface KeyDetails {
type: 'key'
key: string
text: string | null
code: string
Expand All @@ -59,6 +62,13 @@ interface KeyDetails {
}
}

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])/
Expand Down Expand Up @@ -138,20 +148,24 @@ const modifiersToString = (modifiers: KeyboardModifiers) => {
})).join(', ')
}

const joinKeyArrayToString = (keyArr: KeyDetails[]) => {
return _.map(keyArr, (keyDetails) => {
if (keyDetails.text) return keyDetails.key
const joinKeyArrayToString = (keyArr: KeyInfo[]) => {
return _.map(keyArr, (key) => {
if (key.type === 'key') {
if (key.text) return key.key

return `{${keyDetails.key}}`
return `{${key.key}}`
}

return `{${key.originalSequence}}`
}).join('')
}

type modifierKeyDetails = KeyDetails & {
key: keyof typeof keyToModifierMap
}

const isModifier = (details: KeyDetails): details is modifierKeyDetails => {
return !!keyToModifierMap[details.key]
const isModifier = (details: KeyInfo): details is modifierKeyDetails => {
return details.type === 'key' && !!keyToModifierMap[details.key]
}

const getFormattedKeyString = (details: KeyDetails) => {
Expand All @@ -170,7 +184,7 @@ const getFormattedKeyString = (details: KeyDetails) => {
return details.originalSequence
}

const countNumIndividualKeyStrokes = (keys: KeyDetails[]) => {
const countNumIndividualKeyStrokes = (keys: KeyInfo[]) => {
return _.countBy(keys, isModifier)['false']
}

Expand All @@ -188,7 +202,7 @@ const findKeyDetailsOrLowercase = (key: string): KeyDetailsPartial => {
const getTextLength = (str) => _.toArray(str).length

const getKeyDetails = (onKeyNotFound) => {
return (key: string): KeyDetails => {
return (key: string): KeyDetails | ShortcutDetails => {
let foundKey: KeyDetailsPartial

if (getTextLength(key) === 1) {
Expand All @@ -199,6 +213,7 @@ const getKeyDetails = (onKeyNotFound) => {

if (foundKey) {
const details = _.defaults({}, foundKey, {
type: 'key',
key: '',
keyCode: 0,
code: '',
Expand All @@ -211,11 +226,67 @@ const getKeyDetails = (onKeyNotFound) => {
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')
Expand Down Expand Up @@ -311,7 +382,7 @@ const getKeymap = () => {
}
const validateTyping = (
el: HTMLElement,
keys: KeyDetails[],
keys: KeyInfo[],
currentIndex: number,
onFail: Function,
skipCheckUntilIndex: number | undefined,
Expand Down Expand Up @@ -684,12 +755,18 @@ export class Keyboard {

const typeKeyFns = _.map(
keyDetailsArr,
(key: KeyDetails, currentKeyIndex: number) => {
(key: KeyInfo, currentKeyIndex: number) => {
return () => {
debug('typing key:', key.key)

const activeEl = getActiveEl(doc)

if (key.type === 'shortcut') {
this.simulateShortcut(activeEl, key, options)

return null
}

debug('typing key:', key.key)

_skipCheckUntilIndex = _skipCheckUntilIndex && _skipCheckUntilIndex - 1

if (!_skipCheckUntilIndex) {
Expand Down Expand Up @@ -717,21 +794,27 @@ export class Keyboard {
// 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
key.simulatedDefault = _.noop
if (key.type === 'key') {
key.simulatedDefault = _.noop
}
})

_.last(keysToType)!.simulatedDefault = () => {
options.onValueChange(originalText, activeEl)
const lastKeyToType = _.last(keysToType)!

const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType)
if (lastKeyToType.type === 'key') {
lastKeyToType.simulatedDefault = () => {
options.onValueChange(originalText, activeEl)

debug('setting element value', valToSet, activeEl)
const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType)

return $elements.setNativeProp(
activeEl as $elements.HTMLTextLikeInputElement,
'value',
valToSet,
)
debug('setting element value', valToSet, activeEl)

return $elements.setNativeProp(
activeEl as $elements.HTMLTextLikeInputElement,
'value',
valToSet,
)
}
}
}
} else {
Expand Down Expand Up @@ -1126,6 +1209,25 @@ export class Keyboard {
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')
Expand Down
4 changes: 4 additions & 0 deletions packages/driver/src/cypress/error_messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,10 @@ module.exports = {
message: `${cmd('type')} can only be called on a single element. Your subject contained {{num}} elements.`,
docsUrl: 'https://on.cypress.io/type',
},
not_a_modifier: {
message: `\`{{key}}\` is not a modifier.`,
docsUrl: 'https://on.cypress.io/type',
},
not_actionable_textlike: {
message: stripIndent`\
${cmd('type')} failed because it targeted a disabled element.
Expand Down

3 comments on commit 849f382

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 849f382 Nov 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/5.6.0/circle-develop-849f3821d80d24556197f44a39a977c3f72a2b5d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 849f382 Nov 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 ia32 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/5.6.0/appveyor-develop-849f3821d80d24556197f44a39a977c3f72a2b5d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 849f382 Nov 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/5.6.0/appveyor-develop-849f3821d80d24556197f44a39a977c3f72a2b5d/cypress.tgz

Please sign in to comment.