feat: cy.press() (#31398)

* wip - cy.press() command

* cy command for dispatching key press/down/up events

* unit tests and failure cases for cy.press()

* Cypress.Keyboard.Keys definition; fix command log message

* add keys to the internal keyboard type

* auto-focus in cdp

* ensure aut iframe is focused before dispatching key events in bidi browsers

* update tests for cdp focus

* fixed tests for bidi

* lint

* fix type ref in .d.ts

* linting

* skip press() driver test in ff below v135

* try all contexts for frame before failing due to missing/invalid context id

* ensure error is error before accessing props

* skip press driver test in webkit

* changelog

* debug automation middleware invocation for firefox flake

* debug

* cache update

* use bidi automation middleware from connectToNewSpec rather than constructor

* more comprehensive logging

* debug socket base, additional debug in automation

* install firefox automation middleware on setup as well as connectToNewSpec

* unit tests for firefox-utils

* proper calledWith

---------

Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
This commit is contained in:
Cacie Prins
2025-04-08 09:23:18 -04:00
committed by GitHub
parent 9155e05711
commit cfdeb7af14
27 changed files with 792 additions and 153 deletions

View File

@@ -1,8 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 14.2.2
## 14.3.0
_Released 4/8/2025 (PENDING)_
**Features:**
- The [`cy.press()`](https://on.cypress.io/api/press) command is now available. It supports dispatching native Tab keyboard events to the browser. Addresses [#31050](https://github.com/cypress-io/cypress/issues/31050). Addresses [#299](https://github.com/cypress-io/cypress/issues/299). Addressed in [#31398](https://github.com/cypress-io/cypress/pull/31398).
**Bugfixes:**
- Allows for `babel-loader` version 10 to be a peer dependency of `@cypress/webpack-preprocessor`. Fixed in [#31218](https://github.com/cypress-io/cypress/pull/31218).

201
cli/types/cypress.d.ts vendored
View File

@@ -578,99 +578,7 @@ declare namespace Cypress {
*/
stop(): void
Commands: {
/**
* Add a custom command
* @see https://on.cypress.io/api/commands
*/
add<T extends keyof Chainable>(name: T, fn: CommandFn<T>): void
/**
* Add a custom parent command
* @see https://on.cypress.io/api/commands#Parent-Commands
*/
add<T extends keyof Chainable>(name: T, options: CommandOptions & { prevSubject: false }, fn: CommandFn<T>): void
/**
* Add a custom child command
* @see https://on.cypress.io/api/commands#Child-Commands
*/
add<T extends keyof Chainable, S = any>(name: T, options: CommandOptions & { prevSubject: true }, fn: CommandFnWithSubject<T, S>): void
/**
* Add a custom child or dual command
* @see https://on.cypress.io/api/commands#Validations
*/
add<T extends keyof Chainable, S extends PrevSubject>(
name: T, options: CommandOptions & { prevSubject: S | ['optional'] }, fn: CommandFnWithSubject<T, PrevSubjectMap[S]>,
): void
/**
* Add a custom command that allows multiple types as the prevSubject
* @see https://on.cypress.io/api/commands#Validations#Allow-Multiple-Types
*/
add<T extends keyof Chainable, S extends PrevSubject>(
name: T, options: CommandOptions & { prevSubject: S[] }, fn: CommandFnWithSubject<T, PrevSubjectMap<void>[S]>,
): void
/**
* Add one or more custom commands
* @see https://on.cypress.io/api/commands
*/
addAll<T extends keyof Chainable>(fns: CommandFns): void
/**
* Add one or more custom parent commands
* @see https://on.cypress.io/api/commands#Parent-Commands
*/
addAll<T extends keyof Chainable>(options: CommandOptions & { prevSubject: false }, fns: CommandFns): void
/**
* Add one or more custom child commands
* @see https://on.cypress.io/api/commands#Child-Commands
*/
addAll<T extends keyof Chainable, S = any>(options: CommandOptions & { prevSubject: true }, fns: CommandFnsWithSubject<S>): void
/**
* Add one or more custom commands that validate their prevSubject
* @see https://on.cypress.io/api/commands#Validations
*/
addAll<T extends keyof Chainable, S extends PrevSubject>(
options: CommandOptions & { prevSubject: S | ['optional'] }, fns: CommandFnsWithSubject<PrevSubjectMap[S]>,
): void
/**
* Add one or more custom commands that allow multiple types as their prevSubject
* @see https://on.cypress.io/api/commands#Allow-Multiple-Types
*/
addAll<T extends keyof Chainable, S extends PrevSubject>(
options: CommandOptions & { prevSubject: S[] }, fns: CommandFnsWithSubject<PrevSubjectMap<void>[S]>,
): void
/**
* Overwrite an existing Cypress command with a new implementation
* @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands
*/
overwrite<T extends keyof Chainable>(name: T, fn: CommandFnWithOriginalFn<T>): void
/**
* Overwrite an existing Cypress command with a new implementation
* @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands
*/
overwrite<T extends keyof Chainable, S extends PrevSubject>(name: T, fn: CommandFnWithOriginalFnAndSubject<T, PrevSubjectMap[S]>): void
/**
* Add a custom query
* @see https://on.cypress.io/api/custom-queries
*/
addQuery<T extends keyof Chainable>(name: T, fn: QueryFn<T>): void
/**
* Overwrite an existing Cypress query with a new implementation
* @see https://on.cypress.io/api/custom-queries
*/
overwriteQuery<T extends keyof Chainable>(name: T, fn: QueryFnWithOriginalFn<T>): void
}
Commands: Commands
/**
* @see https://on.cypress.io/cookies
@@ -775,6 +683,9 @@ declare namespace Cypress {
*/
Keyboard: {
defaults(options: Partial<KeyboardDefaultsOptions>): void
Keys: {
TAB: 'Tab',
},
}
/**
@@ -829,6 +740,100 @@ declare namespace Cypress {
onSpecWindow: (window: Window, specList: string[] | Array<() => Promise<void>>) => void
}
interface Commands {
/**
* Add a custom command
* @see https://on.cypress.io/api/commands
*/
add<T extends keyof Chainable>(name: T, fn: CommandFn<T>): void
/**
* Add a custom parent command
* @see https://on.cypress.io/api/commands#Parent-Commands
*/
add<T extends keyof Chainable>(name: T, options: CommandOptions & { prevSubject: false }, fn: CommandFn<T>): void
/**
* Add a custom child command
* @see https://on.cypress.io/api/commands#Child-Commands
*/
add<T extends keyof Chainable, S = any>(name: T, options: CommandOptions & { prevSubject: true }, fn: CommandFnWithSubject<T, S>): void
/**
* Add a custom child or dual command
* @see https://on.cypress.io/api/commands#Validations
*/
add<T extends keyof Chainable, S extends PrevSubject>(
name: T, options: CommandOptions & { prevSubject: S | ['optional'] }, fn: CommandFnWithSubject<T, PrevSubjectMap[S]>,
): void
/**
* Add a custom command that allows multiple types as the prevSubject
* @see https://on.cypress.io/api/commands#Validations#Allow-Multiple-Types
*/
add<T extends keyof Chainable, S extends PrevSubject>(
name: T, options: CommandOptions & { prevSubject: S[] }, fn: CommandFnWithSubject<T, PrevSubjectMap<void>[S]>,
): void
/**
* Add one or more custom commands
* @see https://on.cypress.io/api/commands
*/
addAll<T extends keyof Chainable>(fns: CommandFns): void
/**
* Add one or more custom parent commands
* @see https://on.cypress.io/api/commands#Parent-Commands
*/
addAll<T extends keyof Chainable>(options: CommandOptions & { prevSubject: false }, fns: CommandFns): void
/**
* Add one or more custom child commands
* @see https://on.cypress.io/api/commands#Child-Commands
*/
addAll<T extends keyof Chainable, S = any>(options: CommandOptions & { prevSubject: true }, fns: CommandFnsWithSubject<S>): void
/**
* Add one or more custom commands that validate their prevSubject
* @see https://on.cypress.io/api/commands#Validations
*/
addAll<T extends keyof Chainable, S extends PrevSubject>(
options: CommandOptions & { prevSubject: S | ['optional'] }, fns: CommandFnsWithSubject<PrevSubjectMap[S]>,
): void
/**
* Add one or more custom commands that allow multiple types as their prevSubject
* @see https://on.cypress.io/api/commands#Allow-Multiple-Types
*/
addAll<T extends keyof Chainable, S extends PrevSubject>(
options: CommandOptions & { prevSubject: S[] }, fns: CommandFnsWithSubject<PrevSubjectMap<void>[S]>,
): void
/**
* Overwrite an existing Cypress command with a new implementation
* @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands
*/
overwrite<T extends keyof Chainable>(name: T, fn: CommandFnWithOriginalFn<T>): void
/**
* Overwrite an existing Cypress command with a new implementation
* @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands
*/
overwrite<T extends keyof Chainable, S extends PrevSubject>(name: T, fn: CommandFnWithOriginalFnAndSubject<T, PrevSubjectMap[S]>): void
/**
* Add a custom query
* @see https://on.cypress.io/api/custom-queries
*/
addQuery<T extends keyof Chainable>(name: T, fn: QueryFn<T>): void
/**
* Overwrite an existing Cypress query with a new implementation
* @see https://on.cypress.io/api/custom-queries
*/
overwriteQuery<T extends keyof Chainable>(name: T, fn: QueryFnWithOriginalFn<T>): void
}
type CanReturnChainable = void | Chainable | Promise<unknown>
type ThenReturn<S, R> =
R extends void ? Chainable<S> :
@@ -1742,6 +1747,16 @@ declare namespace Cypress {
*/
pause(options?: Partial<Loggable>): Chainable<Subject>
/**
* Send a native sequence of keyboard events: keydown & press, followed by keyup, for the provided key.
* Supported keys index the Cypress.Keyboard.Keys record.
*
* @example
* cy.press(Cypress.Keyboard.Keys.TAB) // dispatches a keydown and press event to the browser, followed by a keyup event.
* @see https://on.cypress.io/press
*/
press(key: typeof Cypress.Keyboard.Keys[keyof typeof Cypress.Keyboard.Keys], options?: Partial<Loggable & Timeoutable>): void
/**
* Get the immediately preceding sibling of each element in a set of the elements.
*

View File

@@ -24,7 +24,9 @@
"jsdoc-format": false,
// for now keep the Cypress NPM module API
// in its own file for simplicity
"no-single-declare-module": false
"no-single-declare-module": false,
// This is detecting necessary qualifiers as unnecessary
"no-unnecessary-qualifier": false
},
"linterOptions": {
"exclude": [

View File

@@ -1,23 +1,21 @@
describe('__placeholder__/commands/actions/press', () => {
describe('src/cy/commands/actions/press', () => {
it('dispatches the tab keypress to the AUT', () => {
// Non-BiDi firefox is not supported
if (Cypress.browser.family === 'firefox' && Cypress.browserMajorVersion() < 135) {
return
}
// TODO: Webkit is not supported. https://github.com/cypress-io/cypress/issues/31054
if (Cypress.isBrowser('webkit')) {
return
}
cy.visit('/fixtures/input_events.html')
cy.get('#focus').focus().then(async () => {
try {
await Cypress.automation('key:press', { key: 'Tab' })
} catch (e) {
if (e.message && (e.message as string).includes('key:press')) {
cy.log(e.message)
cy.press(Cypress.Keyboard.Keys.TAB)
return
}
cy.get('#keydown').should('have.value', 'Tab')
throw e
}
cy.get('#keyup').should('have.value', 'Tab')
cy.get('#keydown').should('have.value', 'Tab')
})
cy.get('#keyup').should('have.value', 'Tab')
})
})

View File

@@ -223,7 +223,7 @@ it('verifies number of cy commands', () => {
'invoke', 'its', 'getCookie', 'getCookies', 'setCookie', 'clearCookie', 'clearCookies', 'pause', 'debug', 'exec', 'readFile',
'writeFile', 'fixture', 'clearLocalStorage', 'url', 'hash', 'location', 'end', 'noop', 'log', 'wrap', 'reload', 'go', 'visit',
'focused', 'get', 'contains', 'shadow', 'within', 'request', 'session', 'screenshot', 'task', 'find', 'filter', 'not',
'children', 'eq', 'closest', 'first', 'last', 'next', 'nextAll', 'nextUntil', 'parent', 'parents', 'parentsUntil', 'prev',
'children', 'eq', 'closest', 'first', 'last', 'next', 'nextAll', 'nextUntil', 'parent', 'parents', 'parentsUntil', 'prev', 'press',
'prevAll', 'prevUntil', 'siblings', 'wait', 'title', 'window', 'document', 'viewport', 'server', 'route', 'intercept', 'origin',
'mount', 'as', 'root', 'getAllLocalStorage', 'clearAllLocalStorage', 'getAllSessionStorage', 'clearAllSessionStorage',
'getAllCookies', 'clearAllCookies',

View File

@@ -12,7 +12,6 @@
</head>
<body>
<input type="text" id="focus" />
<input type="text" id="keyup" />
<input type="text" id="keydown" />
</body>

View File

@@ -9,6 +9,7 @@ import * as Submit from './submit'
import * as Type from './type'
import * as Trigger from './trigger'
import * as Mount from './mount'
import Press from './press'
export {
Check,
@@ -22,4 +23,5 @@ export {
Type,
Trigger,
Mount,
Press,
}

View File

@@ -0,0 +1,77 @@
import type { $Cy } from '../../../cypress/cy'
import type { StateFunc } from '../../../cypress/state'
import type { KeyPressSupportedKeys, AutomationCommands } from '@packages/types'
import { defaults } from 'lodash'
import { isSupportedKey } from '@packages/server/lib/automation/commands/key_press'
import $errUtils from '../../../cypress/error_utils'
import $utils from '../../../cypress/utils'
export interface PressCommand {
(key: KeyPressSupportedKeys, userOptions?: Partial<Cypress.Loggable> & Partial<Cypress.Timeoutable>): void
}
export default function (Commands: Cypress.Commands, Cypress: Cypress.Cypress, cy: $Cy, state: StateFunc, config: any) {
async function pressCommand (key: KeyPressSupportedKeys, userOptions?: Partial<Cypress.Loggable> & Partial<Cypress.Timeoutable>) {
const options: Cypress.Loggable & Partial<Cypress.Timeoutable> = defaults({}, userOptions, {
log: true,
})
const deltaOptions = $utils.filterOutOptions(options)
const log = Cypress.log({
timeout: options.timeout,
hidden: options.log === false,
message: [key, deltaOptions],
consoleProps () {
return {
'Key': key,
}
},
})
if (!isSupportedKey(key)) {
$errUtils.throwErrByPath('press.invalid_key', {
onFail: log,
args: { key },
})
// throwErrByPath always throws, but there's no way to indicate that
// code beyond this point is unreachable to typescript / linters
return
}
if (Cypress.browser.family === 'webkit') {
$errUtils.throwErrByPath('press.unsupported_browser', {
onFail: log,
args: {
family: Cypress.browser.family,
},
})
return
}
if (Cypress.browser.name === 'firefox' && Number(Cypress.browser.majorVersion) < 135) {
$errUtils.throwErrByPath('press.unsupported_browser_version', {
onFail: log,
args: {
browser: Cypress.browser.name,
version: Cypress.browser.majorVersion,
minimumVersion: 135,
},
})
}
try {
const command: 'key:press' = 'key:press'
const args: AutomationCommands[typeof command]['dataType'] = {
key,
}
await Cypress.automation('key:press', args)
} catch (err) {
$errUtils.throwErr(err, { onFail: log })
}
}
return Commands.add('press', pressCommand)
}

View File

@@ -14,6 +14,7 @@ import $utils from '../cypress/utils'
import $window from '../dom/window'
import type { Log } from '../cypress/log'
import type { StateFunc } from '../cypress/state'
import type { KeyPressSupportedKeys } from '@packages/types'
const debug = Debug('cypress:driver:keyboard')
@@ -1397,6 +1398,10 @@ const defaults = (props: Partial<Cypress.KeyboardDefaultsOptions>) => {
return getConfig()
}
const Keys: Record<string, KeyPressSupportedKeys> = {
TAB: 'Tab',
}
export default {
defaults,
getConfig,
@@ -1405,4 +1410,5 @@ export default {
reset,
toModifiersEventOptions,
fromModifierEventOptions,
Keys,
}

View File

@@ -348,6 +348,7 @@ class $Cypress {
this.downloads = $Downloads.create(this)
// wire up command create to cy
// @ts-expect-error
this.Commands = $Commands.create(this, this.cy, this.state, this.config)
this.events.proxyTo(this.cy)

View File

@@ -3,7 +3,7 @@ import { allCommands } from '../cy/commands'
import { addCommand as addNetstubbingCommand } from '../cy/net-stubbing'
import $errUtils from './error_utils'
import $stackUtils from './stack_utils'
import type { $Cy } from './cy'
import type { QueryFunction } from './state'
const PLACEHOLDER_COMMANDS = ['mount', 'hover']
@@ -40,7 +40,7 @@ const internalError = (path, args) => {
}
export default {
create: (Cypress, cy, state, config) => {
create: (Cypress: Cypress.Cypress, cy: $Cy, state, config) => {
const reservedCommandNames = new Set(Object.keys(cy))
const commands = {}
const queries = {}
@@ -63,11 +63,11 @@ export default {
return null
},
addSync (name, fn) {
addSync (name: string, fn: (...args: any[]) => any) {
return cy.addCommandSync(name, fn)
},
addAll (options = {}, obj) {
addAll (options, obj) {
if (!obj) {
obj = options
options = {}

View File

@@ -1310,6 +1310,18 @@ export default {
},
},
press: {
invalid_key: stripIndent`\
\`{{key}}\` is not supported by ${cmd('press')}. See \`Cypress.Keyboard.Keys\` for keys that are supported.
`,
unsupported_browser_version: stripIndent`\
${cmd('press')} is not supported in {{browser}} version {{version}}. Upgrade to version {{minimumVersion}} to use \`cy.press()\`.
`,
unsupported_browser: stripIndent`\
${cmd('press')} is not supported in {{family}} browsers.
`,
},
proxy: {
js_rewriting_failed: stripIndent`\
An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress. Open an issue if you see this message.

View File

@@ -0,0 +1,11 @@
import { vi } from 'vitest'
import type sourceMapUtils from '../../src/cypress/source_map_utils'
// This is mocked in the setup file because vitest chokes on loading the .wasm file
// from the 'source-map' module. A solution to that should be found before unit testing
// source_map_utils.
vi.mock('../../src/cypress/source_map_utils', () => {
return {
getSourcePosition: vi.fn<typeof sourceMapUtils.getSourcePosition>(),
}
})

View File

@@ -0,0 +1,187 @@
/**
* @vitest-environment jsdom
*/
import { vi, describe, it, expect, beforeEach, Mock, MockedObject } from 'vitest'
import type { KeyPressSupportedKeys } from '@packages/types'
import addCommand, { PressCommand } from '../../../../../src/cy/commands/actions/press'
import type { $Cy } from '../../../../../src/cypress/cy'
import type { StateFunc } from '../../../../../src/cypress/state'
import $errUtils from '../../../../../src/cypress/error_utils'
import Keyboard from '../../../../../src/cy/keyboard'
vi.mock('../../../../../src/cypress/error_utils', async () => {
const original = await vi.importActual('../../../../../src/cypress/error_utils')
return {
default: {
// @ts-expect-error
...original.default,
// @ts-expect-error
throwErr: vi.fn().mockImplementation(original.default.throwErr),
// @ts-expect-error
throwErrByPath: vi.fn().mockImplementation(original.default.throwErrByPath),
},
}
})
describe('cy/commands/actions/press', () => {
let log: Mock<typeof Cypress['log']>
let automation: Mock<typeof Cypress['automation']>
let press: PressCommand
let Cypress: MockedObject<Cypress.Cypress>
let Commands: MockedObject<Cypress.Commands>
let cy: MockedObject<$Cy>
let state: MockedObject<StateFunc>
let config: any
let logReturnValue: Cypress.Log
beforeEach(() => {
log = vi.fn<typeof Cypress['log']>()
automation = vi.fn<typeof Cypress['automation']>()
Cypress = {
// The overloads for `log` don't get applied correctly here
// @ts-expect-error
log,
automation,
// @ts-expect-error
browser: {
family: 'chromium',
name: 'Chrome',
},
}
// @ts-expect-error - this is a generic mock impl
Commands = {
add: vi.fn(),
}
// @ts-expect-error
cy = {}
state = {
...vi.fn<StateFunc>(),
// @ts-expect-error - this is a recursive definition, so we're only defining the mock one level deep
state: vi.fn<StateFunc>(),
reset: vi.fn<() => Record<string, any>>(),
}
config = {}
logReturnValue = {
id: 'log_id',
end: vi.fn(),
error: vi.fn(),
finish: vi.fn(),
get: vi.fn(),
set: vi.fn(),
snapshot: vi.fn(),
_hasInitiallyLogged: false,
groupEnd: vi.fn(),
}
Cypress.log.mockReturnValue(logReturnValue)
addCommand(Commands, Cypress, cy, state, config)
expect(Commands.add).toHaveBeenCalledOnce()
// @ts-expect-error
const [[cmdName, cmd]]: [[string, PressCommand]] = Commands.add.mock.calls
expect(cmdName).toEqual('press')
press = cmd as PressCommand
})
describe('with a valid key', () => {
const key: KeyPressSupportedKeys = Keyboard.Keys.TAB
it('dispatches a key:press automation command', async () => {
await press(key)
expect(automation).toHaveBeenCalledWith('key:press', { key })
})
describe('with options', () => {
let options: Cypress.Loggable & Cypress.Timeoutable
beforeEach(() => {
options = {
log: false,
timeout: 2000,
}
})
it('sets timeout and hidden on the log', async () => {
await press(key, options)
expect(Cypress.log).toBeCalledWith({
timeout: options.timeout,
hidden: true,
message: [key, { timeout: 2000 }],
consoleProps: expect.any(Function),
})
})
})
})
describe('with an invalid key', () => {
it('throws an invalid key error', async () => {
// @ts-expect-error
const key: KeyPressSupportedKeys = 'Foo'
await expect(press(key)).rejects.toThrow(`\`${key}\` is not supported by \`cy.press()\``)
expect($errUtils.throwErrByPath).toHaveBeenCalledWith('press.invalid_key', {
onFail: logReturnValue,
args: {
key,
},
})
})
})
describe('when in webkit', () => {
it('throws an unsupported browser error', async () => {
Cypress.browser.family = 'webkit'
await expect(press('Tab')).rejects.toThrow('`cy.press()` is not supported in webkit browsers.')
expect($errUtils.throwErrByPath).toHaveBeenCalledWith('press.unsupported_browser', {
onFail: logReturnValue,
args: {
family: Cypress.browser.family,
},
})
})
})
describe('when in firefox below 135', () => {
it('throws an unsupported browser version error', async () => {
Cypress.browser.name = 'firefox'
Cypress.browser.majorVersion = '134'
await expect(press('Tab')).rejects.toThrow('`cy.press()` is not supported in firefox version 134. Upgrade to version 135 to use `cy.press()`.')
expect($errUtils.throwErrByPath).toHaveBeenCalledWith('press.unsupported_browser_version', {
onFail: logReturnValue,
args: {
browser: Cypress.browser.name,
version: Cypress.browser.majorVersion,
minimumVersion: 135,
},
})
})
})
describe('when automation throws', () => {
it('throws via $errUtils, passing in the results from Cypress.log', async () => {
const thrown = new Error('Some error')
// @ts-expect-error async is not bluebird, but that's fine
Cypress.automation.mockImplementation(async () => {
throw thrown
})
await expect(press('Tab')).rejects.toThrow(thrown)
expect($errUtils.throwErr).toHaveBeenCalledWith(thrown, {
onFail: logReturnValue,
})
})
})
})

View File

@@ -31,6 +31,9 @@ interface InternalCheckOptions extends Partial<Cypress.CheckClearOptions> {
interface InternalKeyboard extends Partial<Keyboard> {
getMap: () => object
reset: () => void
Keys: {
TAB: 'Tab'
}
}
declare namespace Cypress {

View File

@@ -5,6 +5,7 @@ export default defineConfig({
include: ['test/unit/**/*.spec.ts'],
environment: 'jsdom',
exclude: ['**/__fixtures__/**/*'],
setupFiles: 'test/__setup__/setupMocks.ts',
reporters: [
'default',
['junit', { suiteName: 'Driver Unit Tests', outputFile: '/tmp/cypress/junit/driver-test-results.xml' }],

View File

@@ -86,12 +86,12 @@ export class Automation {
const onReq = this.get('onRequest')
if (onReq) {
debug('Middleware `onRequest` fn found, attempting middleware exec for message: %s', message)
return Bluebird.try(() => {
return onReq(resolvedMessage, resolvedData)
}).catch((e) => {
if (AutomationNotImplemented.isAutomationNotImplementedErr(e)) {
debug(`${e.message}. Falling back to emit via socket.`)
return this.requestAutomationResponse(resolvedMessage, resolvedData, fn)
}
@@ -189,6 +189,8 @@ export class Automation {
}
use (middlewares: AutomationMiddleware) {
debug('installing middleware')
return this.middleware = {
...this.middleware,
...middlewares,
@@ -196,6 +198,7 @@ export class Automation {
}
async push<T extends keyof AutomationCommands> (message: T, data: AutomationCommands[T]['dataType']) {
debug('push `%s`: %o', message, data)
const result = await this.normalize(message, data)
if (result) {
@@ -206,6 +209,7 @@ export class Automation {
async request<T extends keyof AutomationCommands> (message: T, data: AutomationCommands[T]['dataType'], fn) {
// curry in the message + callback function
// for obtaining the external automation data
debug('request: `%s`', message)
const automate = this.automationValve(message, fn)
await this.invokeAsync('onBeforeRequest', message, data)

View File

@@ -1,8 +1,10 @@
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping'
import type { Protocol } from 'devtools-protocol'
import type { KeyPressParams, KeyPressSupportedKeys } from '@packages/types'
import type { SendDebuggerCommand } from '../../browsers/cdp_automation'
import type { Client } from 'webdriver'
import Debug from 'debug'
import { isEqual, isError } from 'lodash'
const debug = Debug('cypress:server:automation:command:keypress')
@@ -20,11 +22,41 @@ export class InvalidKeyError extends Error {
}
}
export function isSupportedKey (key: string): key is KeyPressSupportedKeys {
return CDP_KEYCODE[key] && BIDI_VALUE[key]
}
export const CDP_KEYCODE: KeyCodeLookup = {
'Tab': 'U+000009',
}
export async function cdpKeyPress ({ key }: KeyPressParams, send: SendDebuggerCommand): Promise<void> {
async function evaluateInFrameContext (expression: string,
send: SendDebuggerCommand,
contexts: Map<Protocol.Runtime.ExecutionContextId, Protocol.Runtime.ExecutionContextDescription>,
frame: Protocol.Page.Frame): Promise<ProtocolMapping.Commands['Runtime.evaluate']['returnType']> {
for (const [contextId, context] of contexts.entries()) {
if (context.auxData?.frameId === frame.id) {
try {
return await send('Runtime.evaluate', {
expression,
contextId,
})
} catch (e) {
if (isError(e) && (e as Error).message.includes('Cannot find context with specified id')) {
debug('found invalid context %d, removing', contextId)
contexts.delete(contextId)
}
}
}
}
throw new Error('Unable to find valid context for frame')
}
export async function cdpKeyPress (
{ key }: KeyPressParams, send: SendDebuggerCommand,
contexts: Map<Protocol.Runtime.ExecutionContextId, Protocol.Runtime.ExecutionContextDescription>,
frameTree: Protocol.Page.FrameTree,
): Promise<void> {
debug('cdp keypress', { key })
if (!CDP_KEYCODE[key]) {
throw new InvalidKeyError(key)
@@ -32,6 +64,22 @@ export async function cdpKeyPress ({ key }: KeyPressParams, send: SendDebuggerCo
const keyIdentifier = CDP_KEYCODE[key]
const autFrame = frameTree.childFrames?.find(({ frame }) => {
return frame.name?.includes('Your project')
})
if (!autFrame) {
throw new Error('Could not find AUT frame')
}
const topActiveElement = await evaluateInFrameContext('document.activeElement', send, contexts, frameTree.frame)
const autFrameIsActive = topActiveElement.result.description && autFrame.frame.name && topActiveElement.result.description.includes(autFrame.frame.name)
if (!autFrameIsActive) {
await evaluateInFrameContext('window.focus()', send, contexts, autFrame.frame)
}
try {
await send('Input.dispatchKeyEvent', {
type: 'keyDown',
@@ -56,19 +104,32 @@ export const BIDI_VALUE: KeyCodeLookup = {
'Tab': '\uE004',
}
export async function bidiKeyPress ({ key }: KeyPressParams, client: Client, context: string, idSuffix?: string): Promise<void> {
export async function bidiKeyPress ({ key }: KeyPressParams, client: Client, autContext: string, idSuffix?: string): Promise<void> {
const value = BIDI_VALUE[key]
if (!value) {
throw new InvalidKeyError(key)
}
const autFrameElement = await client.findElement('css selector', 'iframe.aut-iframe')
const activeElement = await client.getActiveElement()
if (!isEqual(autFrameElement, activeElement)) {
await client.scriptEvaluate(
{
expression: `window.focus()`,
target: { context: autContext },
awaitPromise: false,
},
)
}
try {
await client.inputPerformActions({
context,
context: autContext,
actions: [{
type: 'key',
id: `${context}-${key}-${idSuffix || Date.now()}`,
id: `${autContext}-${key}-${idSuffix || Date.now()}`,
actions: [
{ type: 'keyDown', value },
{ type: 'keyUp', value },

View File

@@ -79,9 +79,9 @@ export class BidiAutomation {
private interceptId: string | undefined = undefined
private constructor (webDriverClient: WebDriverClient, automation: Automation) {
debug('initializing bidi automation')
this.automation = automation
this.webDriverClient = webDriverClient
// bind Bidi Events to update the standard automation client
// Error here is expected until webdriver adds initiatorType and destination to the request object
// @ts-expect-error
@@ -91,9 +91,6 @@ export class BidiAutomation {
this.webDriverClient.on('network.fetchError', this.onFetchError)
this.webDriverClient.on('browsingContext.contextCreated', this.onBrowsingContextCreated)
this.webDriverClient.on('browsingContext.contextDestroyed', this.onBrowsingContextDestroyed)
debug('registering middleware')
automation.use(this.automationMiddleware)
}
setTopLevelContextId = (contextId?: string) => {
@@ -294,10 +291,10 @@ export class BidiAutomation {
switch (message) {
case 'key:press':
if (this.topLevelContextId) {
await bidiKeyPress(data, this.webDriverClient, this.topLevelContextId)
if (this.autContextId) {
await bidiKeyPress(data, this.webDriverClient, this.autContextId, this.topLevelContextId)
} else {
throw new Error('Cannot emit key press: no top level context initialized')
throw new Error('Cannot emit key press: no AUT context initialized')
}
return

View File

@@ -169,6 +169,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
private frameTree: Protocol.Page.FrameTree | undefined
private gettingFrameTree: Promise<void> | undefined | null
private cachedDataUrlRequestIds: Set<string> = new Set()
private executionContexts: Map<Protocol.Runtime.ExecutionContextId, Protocol.Runtime.ExecutionContextDescription> = new Map()
private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private offFn: OffFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation, private focusTabOnScreenshot: boolean = false, private isHeadless: boolean = false) {
onFn('Network.requestWillBeSent', this.onNetworkRequestWillBeSent)
@@ -178,6 +179,9 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
onFn('ServiceWorker.workerRegistrationUpdated', this.onServiceWorkerRegistrationUpdated)
onFn('ServiceWorker.workerVersionUpdated', this.onServiceWorkerVersionUpdated)
onFn('Runtime.executionContextCreated', this.onExecutionContextCreated)
onFn('Runtime.executionContextDestroyed', this.onExecutionContextDestroyed)
this.on = onFn
this.off = offFn
this.send = sendDebuggerCommandFn
@@ -337,6 +341,18 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
this.automation.onServiceWorkerVersionUpdated?.(params)
}
private onExecutionContextCreated = (event: Protocol.Runtime.ExecutionContextCreatedEvent) => {
debugVerbose('new execution context:', event)
this.executionContexts.set(event.context.id, event.context)
}
private onExecutionContextDestroyed = (event: Protocol.Runtime.ExecutionContextDestroyedEvent) => {
debugVerbose('removing execution context', event)
if (this.executionContexts.has(event.executionContextId)) {
this.executionContexts.delete(event.executionContextId)
}
}
private getAllCookies = (filter: CyCookieFilter) => {
return this.sendDebuggerCommandFn('Network.getAllCookies')
.then((result: Protocol.Network.GetAllCookiesResponse) => {
@@ -588,7 +604,13 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
case 'collect:garbage':
return this.sendDebuggerCommandFn('HeapProfiler.collectGarbage')
case 'key:press':
return cdpKeyPress(data, this.sendDebuggerCommandFn)
if (this.gettingFrameTree) {
debugVerbose('awaiting frame tree')
await this.gettingFrameTree
}
return cdpKeyPress(data, this.sendDebuggerCommandFn, this.executionContexts, (await this.send('Page.getFrameTree')).frameTree)
default:
throw new Error(`No automation handler registered for: '${message}'`)
}

View File

@@ -105,6 +105,8 @@ export default {
// we need to set this to bind our AUT intercepts correctly. Hopefully we can move this in the future on a more sure implementation
client.setTopLevelContextId(contexts[0].context)
automation.use(client.automationMiddleware)
await webdriverClient.browsingContextNavigate({
context: contexts[0].context,
url,

View File

@@ -420,8 +420,13 @@ function shouldUseBiDi (browser: Browser): boolean {
export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
if (shouldUseBiDi(browser)) {
debug('connectToNewSpec bidi')
await firefoxUtil.connectToNewSpecBiDi(options, automation, browserBidiClient!)
debug('registering middleware')
automation.use(browserBidiClient!.automationMiddleware)
} else {
debug('connectToNewSpec cdp')
await firefoxUtil.connectToNewSpecCDP(options, automation, browserCriClient!)
}
}

View File

@@ -185,6 +185,8 @@ export class SocketBase {
message: T,
data: AutomationCommands[T]['dataType'],
) => {
debug('request: %s', message)
return automation.request(message, data, onAutomationClientRequestCallback)
}

View File

@@ -3,32 +3,158 @@ import type { KeyPressSupportedKeys } from '@packages/types'
import type { SendDebuggerCommand } from '../../../../lib/browsers/cdp_automation'
import { cdpKeyPress, bidiKeyPress, BIDI_VALUE, CDP_KEYCODE } from '../../../../lib/automation/commands/key_press'
import { Client as WebdriverClient } from 'webdriver'
import type { Protocol } from 'devtools-protocol'
const { expect, sinon } = require('../../../spec_helper')
describe('key:press automation command', () => {
describe('cdp()', () => {
let sendFn: Sinon.SinonStub<Parameters<SendDebuggerCommand>, ReturnType<SendDebuggerCommand>>
const topFrameId = 'abc'
const autFrameId = 'def'
// @ts-expect-error
const topExecutionContext: Protocol.Runtime.ExecutionContextDescription = {
id: 123,
auxData: {
frameId: topFrameId,
},
}
// @ts-expect-error
const autExecutionContext: Protocol.Runtime.ExecutionContextDescription = {
id: 456,
auxData: {
frameId: autFrameId,
},
}
let executionContexts: Map<Protocol.Runtime.ExecutionContextId, Protocol.Runtime.ExecutionContextDescription> = new Map()
const autFrame = {
frame: {
id: autFrameId,
name: 'Your project',
},
}
const frameTree: Protocol.Page.FrameTree = {
// @ts-expect-error - partial mock of the frame tree
frame: {
id: topFrameId,
},
childFrames: [
// @ts-expect-error - partial mock of the frame tree
autFrame,
],
}
beforeEach(() => {
sendFn = sinon.stub()
executionContexts.set(topExecutionContext.id, topExecutionContext)
executionContexts.set(autExecutionContext.id, autExecutionContext)
})
it('dispaches a keydown followed by a keyup event to the provided send fn with the tab keycode', async () => {
await cdpKeyPress({ key: 'Tab' }, sendFn)
describe('when the aut frame does not have focus', () => {
const topActiveElement: Protocol.Runtime.EvaluateResponse = {
result: {
type: 'object',
description: 'a.some-link',
},
}
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyDown',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
beforeEach(() => {
sendFn.withArgs('Runtime.evaluate', {
expression: 'document.activeElement',
contextId: topExecutionContext.id,
}).resolves(topActiveElement)
})
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyUp',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
it('focuses the frame and sends keydown and keyup', async () => {
await cdpKeyPress({ key: 'Tab' }, sendFn, executionContexts, frameTree)
expect(sendFn).to.have.been.calledWith('Runtime.evaluate', {
expression: 'window.focus()',
contextId: autExecutionContext.id,
})
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyDown',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
})
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyUp',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
})
})
describe('when there are invalid execution contexts associated with the top frame', () => {
// @ts-expect-error - this is a "fake" partial
const invalidExecutionContext: Protocol.Runtime.ExecutionContextDescription = {
id: 9,
auxData: {
frameId: topFrameId,
},
}
beforeEach(() => {
executionContexts = new Map()
executionContexts.set(invalidExecutionContext.id, invalidExecutionContext)
executionContexts.set(topExecutionContext.id, topExecutionContext)
executionContexts.set(autExecutionContext.id, autExecutionContext)
sendFn.withArgs('Runtime.evaluate', {
expression: 'document.activeElement',
contextId: invalidExecutionContext.id,
}).rejects(new Error('Cannot find context with specified id'))
})
it('does not throw', async () => {
let thrown: any = undefined
try {
await cdpKeyPress({ key: 'Tab' }, sendFn, executionContexts, frameTree)
} catch (e) {
thrown = e
}
expect(thrown).to.be.undefined
})
})
})
describe('when the aut frame has focus', () => {
const topActiveElement: Protocol.Runtime.EvaluateResponse = {
result: {
type: 'object',
description: autFrame.frame.name,
},
}
beforeEach(() => {
sendFn.withArgs('Runtime.evaluate', {
expression: 'document.activeElement',
contextId: topExecutionContext.id,
}).resolves(topActiveElement)
})
it('dispaches a keydown followed by a keyup event to the provided send fn with the tab keycode', async () => {
await cdpKeyPress({ key: 'Tab' }, sendFn, executionContexts, frameTree)
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyDown',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
})
expect(sendFn).to.have.been.calledWith('Input.dispatchKeyEvent', {
type: 'keyUp',
keyIdentifier: CDP_KEYCODE.Tab,
key: 'Tab',
code: 'Tab',
})
})
})
@@ -37,15 +163,21 @@ describe('key:press automation command', () => {
// typescript would keep this from happening, but it hasn't yet
// been checked for correctness since being received by automation
// @ts-expect-error
await expect(cdpKeyPress({ key: 'foo' })).to.be.rejectedWith('foo is not supported by \'cy.press()\'.')
await expect(cdpKeyPress({ key: 'foo' }, sendFn, executionContexts, frameTree)).to.be.rejectedWith('foo is not supported by \'cy.press()\'.')
})
})
})
describe('bidi', () => {
let client: Sinon.SinonStubbedInstance<WebdriverClient>
let context: string
let autContext: string
let key: KeyPressSupportedKeys
const iframeElement = {
'element-6066-11e4-a52e-4f735466cecf': 'uuid-1',
}
const otherElement = {
'element-6066-11e4-a52e-4f735466cecf': 'uuid-2',
}
beforeEach(() => {
// can't create a sinon stubbed instance because webdriver doesn't export the constructor. Because it's known that
@@ -53,18 +185,55 @@ describe('key:press automation command', () => {
// @ts-expect-error
client = {
inputPerformActions: (sinon as Sinon.SinonSandbox).stub<Parameters<WebdriverClient['inputPerformActions']>, ReturnType<WebdriverClient['inputPerformActions']>>(),
getActiveElement: (sinon as Sinon.SinonSandbox).stub<Parameters<WebdriverClient['getActiveElement']>, ReturnType<WebdriverClient['getActiveElement']>>(),
findElement: (sinon as Sinon.SinonSandbox).stub<Parameters<WebdriverClient['findElement']>, ReturnType<WebdriverClient['findElement']>>(),
scriptEvaluate: (sinon as Sinon.SinonSandbox).stub<Parameters<WebdriverClient['scriptEvaluate']>, ReturnType<WebdriverClient['scriptEvaluate']>>(),
}
context = 'someContextId'
autContext = 'someContextId'
key = 'Tab'
client.inputPerformActions.resolves()
})
it('calls client.inputPerformActions with a keydown, pause, and keyup action', () => {
bidiKeyPress({ key }, client as WebdriverClient, context, 'idSuffix')
describe('when the aut iframe is not in focus', () => {
beforeEach(() => {
client.findElement.withArgs('css selector ', 'iframe.aut-iframe').resolves(iframeElement)
// @ts-expect-error - webdriver types show this returning a string, but it actually returns an ElementReference, same as findElement
client.getActiveElement.resolves(otherElement)
})
it('focuses the frame before dispatching keydown and keyup', async () => {
await bidiKeyPress({ key }, client as WebdriverClient, autContext, 'idSuffix')
expect(client.scriptEvaluate).to.have.been.calledWith({
expression: 'window.focus()',
target: { context: autContext },
awaitPromise: false,
})
expect(client.inputPerformActions.firstCall.args[0]).to.deep.equal({
context: autContext,
actions: [{
type: 'key',
id: 'someContextId-Tab-idSuffix',
actions: [
{ type: 'keyDown', value: BIDI_VALUE[key] },
{ type: 'keyUp', value: BIDI_VALUE[key] },
],
}],
})
})
})
it('calls client.inputPerformActions with a keydown and keyup action', async () => {
client.findElement.withArgs('css selector ', 'iframe.aut-iframe').resolves(iframeElement)
// @ts-expect-error - webdriver types show this returning a string, but it actually returns an ElementReference, same as findElement
client.getActiveElement.resolves(iframeElement)
await bidiKeyPress({ key }, client as WebdriverClient, autContext, 'idSuffix')
expect(client.inputPerformActions.firstCall.args[0]).to.deep.equal({
context,
context: autContext,
actions: [{
type: 'key',
id: 'someContextId-Tab-idSuffix',

View File

@@ -33,7 +33,7 @@ describe('lib/browsers/bidi_automation', () => {
it('binds BIDI_EVENTS when a new instance is created', () => {
mockWebdriverClient.on = sinon.stub()
const bidiAutoInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
expect(mockWebdriverClient.on).to.have.been.calledWith('network.beforeRequestSent')
expect(mockWebdriverClient.on).to.have.been.calledWith('network.responseStarted')
@@ -41,7 +41,6 @@ describe('lib/browsers/bidi_automation', () => {
expect(mockWebdriverClient.on).to.have.been.calledWith('network.fetchError')
expect(mockWebdriverClient.on).to.have.been.calledWith('browsingContext.contextCreated')
expect(mockWebdriverClient.on).to.have.been.calledWith('browsingContext.contextDestroyed')
expect(mockAutomationClient.use).to.have.been.calledWith(bidiAutoInstance.automationMiddleware)
})
it('unbinds BIDI_EVENTS when close() is called', () => {

View File

@@ -0,0 +1,58 @@
require('../../spec_helper')
import FirefoxUtil from '../../../lib/browsers/firefox-util'
import sinon from 'sinon'
import { expect } from 'chai'
import { Automation } from '../../../lib/automation'
import { Client as WebDriverClient } from 'webdriver'
import { BidiAutomation } from '../../../lib/browsers/bidi_automation'
describe('Firefox-Util', () => {
let automation: sinon.SinonStubbedInstance<Automation>
let onError: sinon.SinonStub<[Error], void>
let url: string
let remotePort: number | undefined
let webdriverClient: Partial<sinon.SinonStubbedInstance<WebDriverClient>>
let useWebDriverBiDi: boolean
let stubbedBiDiAutomation: sinon.SinonStubbedInstance<BidiAutomation>
beforeEach(() => {
automation = sinon.createStubInstance(Automation)
onError = sinon.stub<[Error], void>()
url = 'http://some-url'
remotePort = 8000
webdriverClient = {
sessionSubscribe: sinon.stub<
Parameters<WebDriverClient['sessionSubscribe']>,
ReturnType<WebDriverClient['sessionSubscribe']>
>().resolves(),
browsingContextGetTree: sinon.stub<
Parameters<WebDriverClient['browsingContextGetTree']>,
ReturnType<WebDriverClient['browsingContextGetTree']>
>().resolves({ contexts: [{
context: 'abc',
children: [],
url: 'http://some-url',
userContext: 'user-context',
}] }),
browsingContextNavigate: sinon.stub<
Parameters<WebDriverClient['browsingContextNavigate']>,
ReturnType<WebDriverClient['browsingContextNavigate']>
>().resolves(),
}
useWebDriverBiDi = true
stubbedBiDiAutomation = sinon.createStubInstance(BidiAutomation)
// sinon's createStubInstance doesn't stub out this member method
stubbedBiDiAutomation.setTopLevelContextId = sinon.stub()
sinon.stub(BidiAutomation, 'create').returns(stubbedBiDiAutomation)
})
describe('.setup()', () => {
describe('when using bidi', () => {
it('registers the automation middleware with the automation system', async () => {
await FirefoxUtil.setup({ automation, onError, url, remotePort, webdriverClient, useWebDriverBiDi })
expect(automation.use).to.have.been.calledWith(stubbedBiDiAutomation.automationMiddleware)
})
})
})
})

View File

@@ -162,6 +162,8 @@ describe('lib/browsers/firefox', () => {
context: mockContextId,
url: 'next-spec-url',
})
expect(this.automation.use).to.have.been.calledWith(bidiAutomationClient.automationMiddleware)
})
})