diff --git a/packages/driver/cypress/integration/commands/actions/type_spec.js b/packages/driver/cypress/integration/commands/actions/type_spec.js index 3ae5a4a3039..9a5d41108d3 100644 --- a/packages/driver/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/cypress/integration/commands/actions/type_spec.js @@ -24,6 +24,8 @@ const expectTextEndsWith = (expected) => { } } +const isChromium = Cypress.isBrowser({ family: 'chromium' }) + describe('src/cy/commands/actions/type - #type', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') @@ -643,6 +645,7 @@ describe('src/cy/commands/actions/type - #type', () => { view: cy.state('window'), which: 65, // deprecated but fired by chrome }) + .not.have.property('inputType') done() }) @@ -1810,6 +1813,219 @@ describe('src/cy/commands/actions/type - #type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/7088 + describe('beforeInput event', () => { + it('sends beforeinput in text input', () => { + const call1 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(' ') + expect(e.inputType).eq('insertText') + stub.callsFake(call2) + } + const call2 = (e) => { + expect(e.code).not.exist + expect(e.data).eq('f') + expect(e.inputType).eq('insertText') + stub.callsFake(call3) + } + const call3 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('insertLineBreak') + stub.callsFake(call4) + } + const call4 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentBackward') + stub.callsFake(call5) + } + const call5 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentForward') + } + + const stub = cy.stub() + .callsFake(call1) + + cy.get('input:first') + .then(($el) => { + $el.val('foo bar baz') + $el[0].addEventListener('beforeinput', stub) + }) + .type(' f\n{backspace}') + .type('{moveToStart}{del}') + .then(($el) => { + if (isChromium) { + expect(stub).callCount(5) + expect($el[0].value).eq('oo bar baz ') + } else { + expect(stub, 'should NOT send beforeinput unless in chromium based browser').not.called + } + }) + }) + + it('sends beforeinput in textarea', () => { + const call1 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(' ') + expect(e.inputType).eq('insertText') + stub.callsFake(call2) + } + const call2 = (e) => { + expect(e.code).not.exist + expect(e.data).eq('f') + expect(e.inputType).eq('insertText') + stub.callsFake(call3) + } + const call3 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('insertLineBreak') + stub.callsFake(call4) + } + const call4 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentBackward') + stub.callsFake(call5) + } + const call5 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentForward') + } + + const stub = cy.stub() + .callsFake(call1) + + cy.get('textarea:first') + .then(($el) => { + $el.val('foo bar baz') + $el[0].addEventListener('beforeinput', stub) + }) + .type(' f\n{backspace}') + .type('{moveToStart}{del}') + .then(($el) => { + if (isChromium) { + expect(stub).callCount(5) + expect($el[0].value).eq('oo bar baz f') + } else { + expect(stub, 'should NOT send beforeinput unless in chromium based browser').not.called + } + }) + }) + + it('sends beforeinput in [contenteditable]', () => { + const call1 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(' ') + expect(e.inputType).eq('insertText') + stub.callsFake(call2) + } + const call2 = (e) => { + expect(e.code).not.exist + expect(e.data).eq('f') + expect(e.inputType).eq('insertText') + stub.callsFake(call3) + } + const call3 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('insertParagraph') + stub.callsFake(call4) + } + const call4 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentBackward') + stub.callsFake(call5) + } + const call5 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteContentForward') + } + + const stub = cy.stub() + .callsFake(call1) + + cy.get('#input-types [contenteditable]') + .then(($el) => { + $el.text('foo bar baz') + $el[0].addEventListener('beforeinput', stub) + }) + .type(' f\n{backspace}') + .type('{moveToStart}{del}') + .then(($el) => { + if (isChromium) { + expect(stub).callCount(5) + expect($el[0].textContent).eq('oo bar baz f') + } else { + expect(stub, 'should NOT send beforeinput unless in chromium based browser').not.called + } + }) + }) + + it('beforeinput special inputTypes', () => { + const call1 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(null) + expect(e.inputType).eq('deleteWordForward') + stub.callsFake(call2) + } + const call2 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(null) + expect(e.inputType).eq('deleteHardLineForward') + stub.callsFake(call3) + } + const call3 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteWordBackward') + stub.callsFake(call4) + } + const call4 = (e) => { + expect(e.data).eq(null) + expect(e.inputType).eq('deleteHardLineBackward') + } + + const stub = cy.stub() + .callsFake(call1) + + cy.get('#input-types [contenteditable]') + .then(($el) => { + $el.text('foo bar baz') + $el[0].addEventListener('beforeinput', stub) + }) + .type('{ctrl}{del}') + .type('{ctrl}{shift}{del}') + .type('{ctrl}{backspace}') + .type('{ctrl}{shift}{backspace}') + .then(($el) => { + if (isChromium) { + expect(stub).callCount(4) + } else { + expect(stub, 'should NOT send beforeinput unless in chromium based browser').not.called + } + }) + }) + + it('can cancel beforeinput', () => { + let callCount = 0 + + cy.get('input:first') + .then(($el) => { + $el.val('foo bar baz') + $el[0].addEventListener('beforeinput', (e) => { + callCount++ + e.preventDefault() + }) + }) + .type('foo') + .then(($el) => { + if (isChromium) { + expect(callCount).eq(3) + expect($el[0].value).eq('foo bar baz') + } else { + expect(callCount, 'should NOT send beforeinput unless in chromium based browser').eq(0) + } + }) + }) + }) + // type follows focus // https://github.com/cypress-io/cypress/issues/2240 describe('element reference loss', () => { @@ -3013,18 +3229,20 @@ describe('src/cy/commands/actions/type - #type', () => { // eslint-disable-next-line console.table(table.data, table.columns) + const beforeInput = isChromium ? 'beforeinput, ' : '' + expect(table.name).to.eq('Keyboard Events') const expectedTable = { 1: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keydown', 'Active Modifiers': 'meta', 'Prevented Default': null, 'Target Element': $input[0] }, 2: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 3: { 'Details': '{ code: KeyF, which: 70 }', Typed: 'f', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 4: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 5: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 6: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': 'keydown, keypress, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 7: { 'Details': '{ code: KeyB, which: 66 }', Typed: 'b', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 3: { 'Details': '{ code: KeyF, which: 70 }', Typed: 'f', 'Events Fired': `keydown, keypress, ${beforeInput}textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 4: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, ${beforeInput}textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 5: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, ${beforeInput}textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 6: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, ${beforeInput}keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 7: { 'Details': '{ code: KeyB, which: 66 }', Typed: 'b', 'Events Fired': `keydown, keypress, ${beforeInput}textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, 8: { 'Details': '{ code: ArrowLeft, which: 37 }', Typed: '{leftarrow}', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 9: { 'Details': '{ code: Delete, which: 46 }', Typed: '{del}', 'Events Fired': 'keydown, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 10: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': 'keydown, keypress, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 9: { 'Details': '{ code: Delete, which: 46 }', Typed: '{del}', 'Events Fired': `keydown, ${beforeInput}input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 10: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, ${beforeInput}keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, 11: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keyup', 'Active Modifiers': 'alt', 'Prevented Default': null, 'Target Element': $input[0] }, 12: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, } @@ -3040,8 +3258,10 @@ describe('src/cy/commands/actions/type - #type', () => { cy.get(':text:first').type('f').then(function ($el) { const table = this.lastLog.invoke('consoleProps').table[2]() + const beforeInput = isChromium ? 'beforeinput, ' : '' + expect(table.data).to.deep.eq({ - 1: { Typed: 'f', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': null, 'Target Element': $el[0] }, + 1: { Typed: 'f', 'Events Fired': `keydown, keypress, ${beforeInput}textInput, input, keyup`, 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': null, 'Target Element': $el[0] }, }) }) }) diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index dfdb62f7f40..67c33d2a9fe 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -44,7 +44,7 @@ type SimulatedDefault = ( interface KeyDetails { key: string - text: string + text: string | null code: string keyCode: number location: number @@ -101,6 +101,7 @@ export type KeyEventType = | 'keypress' | 'input' | 'textInput' + | 'beforeinput' const toModifiersEventOptions = (modifiers: KeyboardModifiers) => { return { @@ -201,10 +202,10 @@ const getKeyDetails = (onKeyNotFound) => { key: '', keyCode: 0, code: '', - text: '', + text: null, location: 0, events: {}, - }) + }) as KeyDetails if (getTextLength(details.key) === 1) { details.text = details.key @@ -607,8 +608,10 @@ export interface typeOptions { } export class Keyboard { - constructor (private state: State) { - null + private SUPPORTS_BEFOREINPUT_EVENT + + constructor (private Cypress, private state: State) { + this.SUPPORTS_BEFOREINPUT_EVENT = Cypress.isBrowser({ family: 'chromium' }) } type (opts: typeOptions) { @@ -794,13 +797,14 @@ export class Keyboard { let charCode: number | undefined let keyCode: number | undefined let which: number | undefined - let data: string | undefined + let data: Nullable | 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': @@ -813,7 +817,7 @@ export class Keyboard { } case 'keypress': { - const charCodeAt = keyDetails.text.charCodeAt(0) + const charCodeAt = text!.charCodeAt(0) charCode = charCodeAt keyCode = charCodeAt @@ -829,13 +833,23 @@ export class Keyboard { keyCode = 0 which = 0 location = undefined - data = text + 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 + data = text === '\r' ? null : text location = undefined cancelable = false break @@ -875,6 +889,7 @@ export class Keyboard { data, detail: 0, view: win, + inputType, }, _.isUndefined, ), @@ -922,6 +937,56 @@ export class Keyboard { 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) } @@ -989,6 +1054,7 @@ export class Keyboard { key.events.textInput = false if (key.key !== 'Backspace' && key.key !== 'Delete') { key.events.input = false + key.events.beforeinput = false } } @@ -1023,10 +1089,16 @@ export class Keyboard { this.fireSimulatedEvent(elToType, 'keypress', key, options) ) { if ( - shouldIgnoreEvent('textInput', key.events) || - this.fireSimulatedEvent(elToType, 'textInput', key, options) + !this.SUPPORTS_BEFOREINPUT_EVENT || + shouldIgnoreEvent('beforeinput', key.events) || + this.fireSimulatedEvent(elToType, 'beforeinput', key, options) ) { - return this.performSimulatedDefault(elToType, key, options) + if ( + shouldIgnoreEvent('textInput', key.events) || + this.fireSimulatedEvent(elToType, 'textInput', key, options) + ) { + return this.performSimulatedDefault(elToType, key, options) + } } } } @@ -1135,8 +1207,8 @@ export class Keyboard { } } -const create = (state) => { - return new Keyboard(state) +const create = (Cypress, state) => { + return new Keyboard(Cypress, state) } export { diff --git a/packages/driver/src/cypress/cy.js b/packages/driver/src/cypress/cy.js index d75770ff1d1..4f4c73b7fae 100644 --- a/packages/driver/src/cypress/cy.js +++ b/packages/driver/src/cypress/cy.js @@ -150,7 +150,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const jquery = $jQuery.create(state) const location = $Location.create(state) const focused = $Focused.create(state) - const keyboard = $Keyboard.create(state) + const keyboard = $Keyboard.create(Cypress, state) const mouse = $Mouse.create(state, keyboard, focused, Cypress) const timers = $Timers.create()