diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 6d9b5625f126..515ee6236ad6 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -490,6 +490,13 @@ declare namespace Cypress { getElementCoordinatesByPositionRelativeToXY(element: JQuery | HTMLElement, x: number, y: number): ElementPositioning } + /** + * @see https://on.cypress.io/keyboard-api + */ + Keyboard: { + defaults(options: Partial): void + } + /** * @see https://on.cypress.io/api/api-server */ @@ -2786,6 +2793,7 @@ declare namespace Cypress { interface TestConfigOverrides extends Partial> { browser?: IsBrowserMatcher | IsBrowserMatcher[] + keystrokeDelay?: number } /** @@ -2836,6 +2844,18 @@ declare namespace Cypress { env: object } + /** + * Options for Cypress.Keyboard.defaults() + */ + interface KeyboardDefaultsOptions { + /** + * Time, in milliseconds, between each keystroke when typing. (Pass 0 to disable) + * + * @default 10 + */ + keystrokeDelay: number + } + /** * Full set of possible options for cy.request call */ diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index 04b8a0b20110..8e0dc853dcce 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -592,17 +592,19 @@ namespace CypressTestConfigOverridesTests { browser: [{name: 'firefox'}, {name: 'chrome'}] }, () => {}) it('test', { - browser: 'firefox' + browser: 'firefox', + keystrokeDelay: 0 }, () => {}) it('test', { - browser: {foo: 'bar'} // $ExpectError + browser: {foo: 'bar'}, // $ExpectError }, () => {}) - it('test', { - retries: null + retries: null, + keystrokeDelay: 0 }, () => { }) it('test', { - retries: 3 + retries: 3, + keystrokeDelay: false, // $ExpectError }, () => { }) it('test', { retries: { @@ -631,14 +633,16 @@ namespace CypressTestConfigOverridesTests { // set config on a per-suite basis describe('suite', { browser: {family: 'firefox'}, - baseUrl: 'www.example.com' + baseUrl: 'www.example.com', + keystrokeDelay: 0 }, () => {}) context('suite', {}, () => {}) describe('suite', { browser: {family: 'firefox'}, - baseUrl: 'www.example.com' + baseUrl: 'www.example.com', + keystrokeDelay: false // $ExpectError foo: 'foo' // $ExpectError }, () => {}) @@ -672,3 +676,18 @@ namespace CypressTaskTests { val // $ExpectType unknown }) } + +namespace CypressKeyboardTests { + Cypress.Keyboard.defaults({ + keystrokeDelay: 0 + }) + Cypress.Keyboard.defaults({ + keystrokeDelay: 500 + }) + Cypress.Keyboard.defaults({ + keystrokeDelay: false // $ExpectError + }) + Cypress.Keyboard.defaults({ + delay: 500 // $ExpectError + }) +} diff --git a/packages/driver/cypress/integration/commands/actions/type_spec.js b/packages/driver/cypress/integration/commands/actions/type_spec.js index ff8dffafa3c5..6dd9c75c922e 100644 --- a/packages/driver/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/cypress/integration/commands/actions/type_spec.js @@ -639,6 +639,16 @@ describe('src/cy/commands/actions/type - #type', () => { }) describe('delay', () => { + it('adds default delay to delta for each key sequence', () => { + cy.spy(cy, 'timeout') + + cy.get(':text:first') + .type('foo{enter}bar{leftarrow}') + .then(() => { + expect(cy.timeout).to.be.calledWith(10 * 8, true, 'type') + }) + }) + it('adds delay to delta for each key sequence', () => { cy.spy(cy, 'timeout') @@ -667,6 +677,72 @@ describe('src/cy/commands/actions/type - #type', () => { cy.get(':text:first').type('foo{enter}bar{leftarrow}') }) + + it('test config keystrokeDelay overrides global value', { keystrokeDelay: 5 }, () => { + cy.spy(cy, 'timeout') + + cy.get(':text:first') + .type('foo{enter}bar{leftarrow}') + .then(() => { + expect(cy.timeout).to.be.calledWith(5 * 8, true, 'type') + }) + }) + + it('delay will override default keystrokeDelay', () => { + Cypress.Keyboard.defaults({ + keystrokeDelay: 20, + }) + + cy.spy(cy, 'timeout') + + cy.get(':text:first') + .type('foo{enter}bar{leftarrow}', { delay: 5 }) + .then(() => { + expect(cy.timeout).to.be.calledWith(5 * 8, true, 'type') + + Cypress.Keyboard.reset() + }) + }) + + it('delay will override test config keystrokeDelay', { keystrokeDelay: 1000 }, () => { + cy.spy(cy, 'timeout') + + cy.get(':text:first') + .type('foo{enter}bar{leftarrow}', { delay: 5 }) + .then(() => { + expect(cy.timeout).to.be.calledWith(5 * 8, true, 'type') + }) + }) + + it('does not increase the timeout delta when delay is 0', () => { + cy.spy(cy, 'timeout') + + cy.get(':text:first').type('foo{enter}', { delay: 0 }).then(() => { + expect(cy.timeout).not.to.be.calledWith(0, true, 'type') + }) + }) + + describe('errors', () => { + it('throws when delay is invalid', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('`cy.type()` `delay` option must be 0 (zero) or a positive number. You passed: `false`') + expect(err.docsUrl).to.equal('https://on.cypress.io/type') + done() + }) + + cy.get(':text:first').type('foo', { delay: false }) + }) + + it('throws when test config keystrokeDelay is invalid', { keystrokeDelay: false }, (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('The test configuration `keystrokeDelay` option must be 0 (zero) or a positive number. You passed: `false`') + expect(err.docsUrl).to.equal('https://on.cypress.io/test-configuration') + done() + }) + + cy.get(':text:first').type('foo') + }) + }) }) describe('events', () => { diff --git a/packages/driver/cypress/integration/cypress/keyboard_spec.js b/packages/driver/cypress/integration/cypress/keyboard_spec.js new file mode 100644 index 000000000000..9ab4a4353ccd --- /dev/null +++ b/packages/driver/cypress/integration/cypress/keyboard_spec.js @@ -0,0 +1,108 @@ +const { Keyboard } = Cypress + +const DEFAULTS = { + keystrokeDelay: 10, +} + +describe('src/cypress/keyboard', () => { + beforeEach(() => { + Keyboard.reset() + }) + + it('has defaults', () => { + expect(Keyboard.getConfig()).to.deep.eq(DEFAULTS) + }) + + context('.getConfig', () => { + it('returns config', () => { + expect(Keyboard.getConfig()).to.deep.eq(DEFAULTS) + }) + + it('does not allow mutation of config', () => { + const config = Keyboard.getConfig() + + config.keystrokeDelay = 0 + + expect(Keyboard.getConfig().keystrokeDelay).to.eq(DEFAULTS.keystrokeDelay) + }) + }) + + context('.defaults', () => { + it('is noop if not called with any valid properties', () => { + Keyboard.defaults({}) + expect(Keyboard.getConfig()).to.deep.eq(DEFAULTS) + }) + + it('sets keystrokeDelay if specified', () => { + Keyboard.defaults({ + keystrokeDelay: 5, + }) + + expect(Keyboard.getConfig().keystrokeDelay).to.eql(5) + }) + + it('returns new config', () => { + const result = Keyboard.defaults({ + keystrokeDelay: 5, + }) + + expect(result).to.deep.eql({ + keystrokeDelay: 5, + }) + }) + + it('does not allow mutation via returned config', () => { + const result = Keyboard.defaults({ + keystrokeDelay: 5, + }) + + result.keystrokeDelay = 0 + + expect(Keyboard.getConfig().keystrokeDelay).to.eq(5) + }) + + describe('errors', () => { + it('throws if not passed an object', () => { + const fn = () => { + Keyboard.defaults() + } + + expect(fn).to.throw() + .with.property('message') + .and.eq('`Cypress.Keyboard.defaults()` must be called with an object. You passed: ``') + + expect(fn).to.throw() + .with.property('docsUrl') + .and.eq('https://on.cypress.io/keyboard-api') + }) + + it('throws if keystrokeDelay is not a number', () => { + const fn = () => { + Keyboard.defaults({ keystrokeDelay: false }) + } + + expect(fn).to.throw() + .with.property('message') + .and.eq('`Cypress.Keyboard.defaults()` `keystrokeDelay` option must be 0 (zero) or a positive number. You passed: `false`') + + expect(fn).to.throw() + .with.property('docsUrl') + .and.eq('https://on.cypress.io/keyboard-api') + }) + + it('throws if keystrokeDelay is a negative number', () => { + const fn = () => { + Keyboard.defaults({ keystrokeDelay: -10 }) + } + + expect(fn).to.throw() + .with.property('message') + .and.eq('`Cypress.Keyboard.defaults()` `keystrokeDelay` option must be 0 (zero) or a positive number. You passed: `-10`') + + expect(fn).to.throw() + .with.property('docsUrl') + .and.eq('https://on.cypress.io/keyboard-api') + }) + }) + }) +}) diff --git a/packages/driver/src/cy/commands/actions/type.js b/packages/driver/src/cy/commands/actions/type.js index 016ce5fab69f..6e29a84e2adb 100644 --- a/packages/driver/src/cy/commands/actions/type.js +++ b/packages/driver/src/cy/commands/actions/type.js @@ -24,7 +24,7 @@ module.exports = function (Commands, Cypress, cy, state, config) { log: true, verify: true, force: false, - delay: 10, + delay: config('keystrokeDelay') || $Keyboard.getConfig().keystrokeDelay, release: true, parseSpecialCharSequences: true, waitForAnimations: config('waitForAnimations'), @@ -130,6 +130,30 @@ module.exports = function (Commands, Cypress, cy, state, config) { }) } + const isInvalidDelay = (delay) => { + return delay !== undefined && (!_.isNumber(delay) || delay < 0) + } + + if (isInvalidDelay(userOptions.delay)) { + $errUtils.throwErrByPath('keyboard.invalid_delay', { + onFail: options._log, + args: { + cmd: 'type', + docsPath: 'type', + option: 'delay', + delay: userOptions.delay, + }, + }) + } + + // specific error if test config keystrokeDelay is invalid + if (isInvalidDelay(config('keystrokeDelay'))) { + $errUtils.throwErrByPath('keyboard.invalid_per_test_delay', { + onFail: options._log, + args: { delay: config('keystrokeDelay') }, + }) + } + chars = `${chars}` const win = state('window') @@ -282,7 +306,9 @@ module.exports = function (Commands, Cypress, cy, state, config) { // 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') + if (options.delay) { + return cy.timeout(totalKeys * options.delay, true, 'type') + } }, onEvent: updateTable || _.noop, diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index d8253b9323a9..12554669e1eb 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -10,6 +10,7 @@ import * as $elements from '../dom/elements' // eslint-disable-next-line no-duplicate-imports import { HTMLTextLikeElement } from '../dom/elements' import * as $selection from '../dom/selection' +import $utils from '../cypress/utils' import $window from '../dom/window' const debug = Debug('cypress:driver:keyboard') @@ -840,9 +841,14 @@ export class Keyboard { return Promise .each(typeKeyFns, (fn) => { + if (options.delay) { + return Promise + .try(fn) + .delay(options.delay) + } + return Promise .try(fn) - .delay(options.delay) }) .then(() => { if (options.release !== false) { @@ -1315,10 +1321,56 @@ const create = (Cypress, state) => { return new Keyboard(Cypress, state) } +let _defaults + +const reset = () => { + _defaults = { + keystrokeDelay: 10, + } +} + +reset() + +const getConfig = () => { + return _.clone(_defaults) +} + +const defaults = (props: Partial) => { + if (!_.isPlainObject(props)) { + $errUtils.throwErrByPath('keyboard.invalid_arg', { + args: { arg: $utils.stringify(props) }, + }) + } + + if (!('keystrokeDelay' in props)) { + return getConfig() + } + + if (!_.isNumber(props.keystrokeDelay) || props.keystrokeDelay! < 0) { + $errUtils.throwErrByPath('keyboard.invalid_delay', { + args: { + cmd: 'Cypress.Keyboard.defaults', + docsPath: 'keyboard-api', + option: 'keystrokeDelay', + delay: $utils.stringify(props.keystrokeDelay), + }, + }) + } + + _.extend(_defaults, { + keystrokeDelay: props.keystrokeDelay, + }) + + return getConfig() +} + export { create, + defaults, + getConfig, getKeymap, modifiersToString, + reset, toModifiersEventOptions, fromModifierEventOptions, } diff --git a/packages/driver/src/cypress/error_messages.js b/packages/driver/src/cypress/error_messages.js index 25ea198ff56c..16814947e632 100644 --- a/packages/driver/src/cypress/error_messages.js +++ b/packages/driver/src/cypress/error_messages.js @@ -668,6 +668,24 @@ module.exports = { docsUrl: 'https://on.cypress.io/{{cmd}}', }, }, + + keyboard: { + invalid_arg: { + message: `${cmd('Cypress.Keyboard.defaults')} must be called with an object. You passed: \`{{arg}}\``, + docsUrl: 'https://on.cypress.io/keyboard-api', + }, + invalid_delay ({ cmd: command, option, delay, docsPath }) { + return { + message: `${cmd(command)} \`${option}\` option must be 0 (zero) or a positive number. You passed: \`${delay}\``, + docsUrl: `https://on.cypress.io/${docsPath}`, + } + }, + invalid_per_test_delay: { + message: `The test configuration \`keystrokeDelay\` option must be 0 (zero) or a positive number. You passed: \`{{delay}}\``, + docsUrl: 'https://on.cypress.io/test-configuration', + }, + }, + location: { invalid_key: { message: 'Location object does not have key: `{{key}}`', @@ -917,7 +935,7 @@ module.exports = { reached_redirection_limit ({ href, limit }) { return stripIndent`\ The application redirected to \`${href}\` more than ${limit} times. Please check if it's an intended behavior. - + If so, increase \`redirectionLimit\` value in configuration.` }, }, @@ -934,7 +952,7 @@ module.exports = { extra_arguments: ({ argsLength, overload }) => { return cyStripIndent(`\ The ${cmd('intercept', overload.join(', '))} signature accepts a maximum of ${overload.length} arguments, but ${argsLength} arguments were passed. - + Please refer to the docs for all accepted signatures for ${cmd('intercept')}.`, 10) }, invalid_handler: ({ handler }) => { @@ -977,9 +995,9 @@ module.exports = { unknown_event: ({ validEvents, eventName }) => { return cyStripIndent(`\ An invalid event name was passed as the first parameter to \`req.on()\`. - + Valid event names are: ${format(validEvents)} - + You passed: ${format(eventName)}`, 10) }, event_needs_handler: `\`req.on()\` requires the second parameter to be a function.`, @@ -1910,7 +1928,7 @@ module.exports = { ${cmd('visit')} failed because the 'file://...' protocol is not supported by Cypress. To visit a local file, you can pass in the relative path to the file from the \`projectRoot\` (Note: if the configuration value \`baseUrl\` is set, the supplied path will be resolved from the \`baseUrl\` instead of \`projectRoot\`)`, - docsUrl: ['https://docs.cypress.io/api/commands/visit.html', '/https://docs.cypress.io/api/cypress-api/config.html'], + docsUrl: 'https://on.cypress.io/visit', }, },