Skip to content

Commit 849f382

Browse files
sainthkhkuceb
andauthored
feat: Support shortcut syntax in cy.type() (#8499)
Co-authored-by: Ben Kucera <14625260+Bkucera@users.noreply.github.com>
1 parent 82d2968 commit 849f382

3 files changed

Lines changed: 199 additions & 22 deletions

File tree

packages/driver/cypress/integration/commands/actions/type_spec.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,6 +2372,77 @@ describe('src/cy/commands/actions/type - #type', () => {
23722372
})
23732373
})
23742374

2375+
// https://github.com/cypress-io/cypress/issues/5694
2376+
describe('shortcuts', () => {
2377+
beforeEach(function () {
2378+
cy.visit('fixtures/dom.html')
2379+
cy.on('log:added', (attrs, log) => {
2380+
this.lastLog = log
2381+
})
2382+
})
2383+
2384+
it('releases modfier keys at the end of the shortcut sequence', () => {
2385+
cy.get(':text:first').type('h{ctrl+alt++}i')
2386+
.then(function ($input) {
2387+
const table = this.lastLog.invoke('consoleProps').table[2]()
2388+
2389+
// eslint-disable-next-line
2390+
console.table(table.data, table.columns)
2391+
2392+
const beforeinput = Cypress.isBrowser('firefox') ? '' : ' beforeinput,'
2393+
2394+
expect(table.name).to.eq('Keyboard Events')
2395+
const expectedTable = {
2396+
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] },
2397+
2: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keydown', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
2398+
3: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
2399+
4: { 'Details': '{ code: Equal, which: 187 }', Typed: '+', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
2400+
5: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keyup', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] },
2401+
6: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] },
2402+
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] },
2403+
}
2404+
2405+
// uncomment for debugging
2406+
// _.each(table.data, (v, i) => expect(v).containSubset(expectedTable[i]))
2407+
expect(table.data).to.deep.eq(expectedTable)
2408+
})
2409+
})
2410+
2411+
it('can type a shortcut with special characters', () => {
2412+
// NOTE: the default actions we implement will NOT be taken into account with modifiers
2413+
// e.g. we do not the delete the entire word with ctrl+backspace
2414+
// this matches the same behavior as cy.type('{ctrl}{backspace}')
2415+
// TODO: maybe change this in the future, it's just more work
2416+
cy.get(':text:first').type('foo{ctrl+backspace}bar')
2417+
.should('have.value', 'fobar')
2418+
})
2419+
2420+
it('does not input text when non-shift modifier', () => {
2421+
// NOTE: in this case the modifier DOES change the default action (when modifier other than Shift is applied, do not insert text)
2422+
// since most users want to test a user issuing a shortcut, and it's simple for us to implement
2423+
cy.get(':text:first').type('{ctrl+b}hi')
2424+
.should('have.value', 'hi')
2425+
})
2426+
2427+
it('throws an error when a wrong modifier is given', () => {
2428+
cy.on('fail', (err) => {
2429+
expect(err.message).to.eq('`asdf` is not a modifier.')
2430+
})
2431+
2432+
cy.get(':text:first').type('{asdf+x}hi')
2433+
})
2434+
2435+
it('throws an error when shortcut is missing key', () => {
2436+
cy.on('fail', (err) => {
2437+
expect(err.message).to.contain('{ctrl+}')
2438+
expect(err.message).to.contain('is not recognized')
2439+
expect(err.message).to.contain('alt, option, ctrl')
2440+
})
2441+
2442+
cy.get(':text:first').type('{ctrl+}hi')
2443+
})
2444+
})
2445+
23752446
describe('case-insensitivity', () => {
23762447
it('special chars are case-insensitive', () => {
23772448
cy.get(':text:first').invoke('val', 'bar').type('{leftarrow}{DeL}').then(($input) => {

packages/driver/src/cy/keyboard.ts

Lines changed: 124 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ type SimulatedDefault = (
4242
options: typeOptions
4343
) => void
4444

45+
type KeyInfo = KeyDetails | ShortcutDetails
46+
4547
interface KeyDetails {
48+
type: 'key'
4649
key: string
4750
text: string | null
4851
code: string
@@ -59,6 +62,13 @@ interface KeyDetails {
5962
}
6063
}
6164

65+
interface ShortcutDetails {
66+
type: 'shortcut'
67+
modifiers: KeyDetails[]
68+
key: KeyDetails
69+
originalSequence: string
70+
}
71+
6272
const dateRe = /^\d{4}-\d{2}-\d{2}/
6373
const monthRe = /^\d{4}-(0\d|1[0-2])/
6474
const weekRe = /^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])/
@@ -138,20 +148,24 @@ const modifiersToString = (modifiers: KeyboardModifiers) => {
138148
})).join(', ')
139149
}
140150

141-
const joinKeyArrayToString = (keyArr: KeyDetails[]) => {
142-
return _.map(keyArr, (keyDetails) => {
143-
if (keyDetails.text) return keyDetails.key
151+
const joinKeyArrayToString = (keyArr: KeyInfo[]) => {
152+
return _.map(keyArr, (key) => {
153+
if (key.type === 'key') {
154+
if (key.text) return key.key
144155

145-
return `{${keyDetails.key}}`
156+
return `{${key.key}}`
157+
}
158+
159+
return `{${key.originalSequence}}`
146160
}).join('')
147161
}
148162

149163
type modifierKeyDetails = KeyDetails & {
150164
key: keyof typeof keyToModifierMap
151165
}
152166

153-
const isModifier = (details: KeyDetails): details is modifierKeyDetails => {
154-
return !!keyToModifierMap[details.key]
167+
const isModifier = (details: KeyInfo): details is modifierKeyDetails => {
168+
return details.type === 'key' && !!keyToModifierMap[details.key]
155169
}
156170

157171
const getFormattedKeyString = (details: KeyDetails) => {
@@ -170,7 +184,7 @@ const getFormattedKeyString = (details: KeyDetails) => {
170184
return details.originalSequence
171185
}
172186

173-
const countNumIndividualKeyStrokes = (keys: KeyDetails[]) => {
187+
const countNumIndividualKeyStrokes = (keys: KeyInfo[]) => {
174188
return _.countBy(keys, isModifier)['false']
175189
}
176190

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

190204
const getKeyDetails = (onKeyNotFound) => {
191-
return (key: string): KeyDetails => {
205+
return (key: string): KeyDetails | ShortcutDetails => {
192206
let foundKey: KeyDetailsPartial
193207

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

200214
if (foundKey) {
201215
const details = _.defaults({}, foundKey, {
216+
type: 'key',
202217
key: '',
203218
keyCode: 0,
204219
code: '',
@@ -211,11 +226,67 @@ const getKeyDetails = (onKeyNotFound) => {
211226
details.text = details.key
212227
}
213228

229+
details.type = 'key'
214230
details.originalSequence = key
215231

216232
return details
217233
}
218234

235+
if (key.includes('+')) {
236+
if (key.endsWith('++')) {
237+
key = key.replace('++', '+plus')
238+
}
239+
240+
const keys = key.split('+')
241+
let lastKey = _.last(keys)
242+
243+
if (lastKey === 'plus') {
244+
keys[keys.length - 1] = '+'
245+
lastKey = '+'
246+
}
247+
248+
if (!lastKey) {
249+
return onKeyNotFound(key, _.keys(getKeymap()).join(', '))
250+
}
251+
252+
const keyWithModifiers = getKeyDetails(onKeyNotFound)(lastKey) as KeyDetails
253+
254+
let hasModifierBesidesShift = false
255+
256+
const modifiers = keys.slice(0, -1)
257+
.map((m) => {
258+
if (!Object.keys(modifierChars).includes(m)) {
259+
$errUtils.throwErrByPath('type.not_a_modifier', {
260+
args: {
261+
key: m,
262+
},
263+
})
264+
}
265+
266+
if (m !== 'shift') {
267+
hasModifierBesidesShift = true
268+
}
269+
270+
return getKeyDetails(onKeyNotFound)(m)
271+
}) as KeyDetails[]
272+
273+
const details: ShortcutDetails = {
274+
type: 'shortcut',
275+
modifiers,
276+
key: keyWithModifiers,
277+
originalSequence: key,
278+
}
279+
280+
// if we are going to type {ctrl+b}, the 'b' shouldn't be input as text
281+
// normally we don't bypass text input but for shortcuts it's definitely what the user wants
282+
// since the modifiers only apply to this single key.
283+
if (hasModifierBesidesShift) {
284+
details.key.text = null
285+
}
286+
287+
return details
288+
}
289+
219290
onKeyNotFound(key, _.keys(getKeymap()).join(', '))
220291

221292
throw new Error('this can never happen')
@@ -311,7 +382,7 @@ const getKeymap = () => {
311382
}
312383
const validateTyping = (
313384
el: HTMLElement,
314-
keys: KeyDetails[],
385+
keys: KeyInfo[],
315386
currentIndex: number,
316387
onFail: Function,
317388
skipCheckUntilIndex: number | undefined,
@@ -684,12 +755,18 @@ export class Keyboard {
684755

685756
const typeKeyFns = _.map(
686757
keyDetailsArr,
687-
(key: KeyDetails, currentKeyIndex: number) => {
758+
(key: KeyInfo, currentKeyIndex: number) => {
688759
return () => {
689-
debug('typing key:', key.key)
690-
691760
const activeEl = getActiveEl(doc)
692761

762+
if (key.type === 'shortcut') {
763+
this.simulateShortcut(activeEl, key, options)
764+
765+
return null
766+
}
767+
768+
debug('typing key:', key.key)
769+
693770
_skipCheckUntilIndex = _skipCheckUntilIndex && _skipCheckUntilIndex - 1
694771

695772
if (!_skipCheckUntilIndex) {
@@ -717,21 +794,27 @@ export class Keyboard {
717794
// singleValueChange inputs must have their value set once at the end
718795
// performing the simulatedDefault for a key would try to insert text on each character
719796
// we still send all the events as normal, however
720-
key.simulatedDefault = _.noop
797+
if (key.type === 'key') {
798+
key.simulatedDefault = _.noop
799+
}
721800
})
722801

723-
_.last(keysToType)!.simulatedDefault = () => {
724-
options.onValueChange(originalText, activeEl)
802+
const lastKeyToType = _.last(keysToType)!
725803

726-
const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType)
804+
if (lastKeyToType.type === 'key') {
805+
lastKeyToType.simulatedDefault = () => {
806+
options.onValueChange(originalText, activeEl)
727807

728-
debug('setting element value', valToSet, activeEl)
808+
const valToSet = isClearChars ? '' : joinKeyArrayToString(keysToType)
729809

730-
return $elements.setNativeProp(
731-
activeEl as $elements.HTMLTextLikeInputElement,
732-
'value',
733-
valToSet,
734-
)
810+
debug('setting element value', valToSet, activeEl)
811+
812+
return $elements.setNativeProp(
813+
activeEl as $elements.HTMLTextLikeInputElement,
814+
'value',
815+
valToSet,
816+
)
817+
}
735818
}
736819
}
737820
} else {
@@ -1126,6 +1209,25 @@ export class Keyboard {
11261209
this.simulatedKeyup(elToKeyup, key, options)
11271210
}
11281211

1212+
simulateShortcut (el: HTMLElement, key: ShortcutDetails, options) {
1213+
key.modifiers.forEach((key) => {
1214+
this.simulatedKeydown(el, key, options)
1215+
})
1216+
1217+
this.simulatedKeydown(el, key.key, options)
1218+
this.simulatedKeyup(el, key.key, options)
1219+
1220+
options.id = _.uniqueId('char')
1221+
1222+
const elToKeyup = this.getActiveEl(options)
1223+
1224+
key.modifiers.reverse().forEach((key) => {
1225+
delete key.events.keyup
1226+
options.id = _.uniqueId('char')
1227+
this.simulatedKeyup(elToKeyup, key, options)
1228+
})
1229+
}
1230+
11291231
simulatedKeyup (el: HTMLElement, _key: KeyDetails, options: typeOptions) {
11301232
if (shouldIgnoreEvent('keyup', _key.events)) {
11311233
debug('simulatedKeyup: ignoring event')

packages/driver/src/cypress/error_messages.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,6 +1689,10 @@ module.exports = {
16891689
message: `${cmd('type')} can only be called on a single element. Your subject contained {{num}} elements.`,
16901690
docsUrl: 'https://on.cypress.io/type',
16911691
},
1692+
not_a_modifier: {
1693+
message: `\`{{key}}\` is not a modifier.`,
1694+
docsUrl: 'https://on.cypress.io/type',
1695+
},
16921696
not_actionable_textlike: {
16931697
message: stripIndent`\
16941698
${cmd('type')} failed because it targeted a disabled element.

0 commit comments

Comments
 (0)