>>),
}
autContext = 'someContextId'
- key = 'Tab'
+ key = toSupportedKey('Tab')
+ client.switchToWindow.resolves()
client.inputPerformActions.resolves()
client.browsingContextGetTree.resolves({
contexts: [
@@ -215,6 +296,8 @@ describe('key:press automation command', () => {
children: [],
url: 'someUrl',
userContext: 'userContext',
+ clientWindow: 'clientWindow',
+ originalOpener: 'originalOpener',
},
],
})
@@ -223,30 +306,36 @@ describe('key:press automation command', () => {
describe('when the aut iframe is not in focus', () => {
beforeEach(() => {
client.getWindowHandle.resolves(topLevelContext)
- client.findElement.withArgs('css selector ', 'iframe.aut-iframe').resolves(iframeElement)
+ 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')
+ it('focuses the frame before dispatching keydown and keyup, and then releases the input actions', async () => {
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
expect(client.scriptEvaluate).to.have.been.calledWith({
expression: 'window.focus()',
target: { context: autContext },
awaitPromise: false,
})
+ const expectedValue = BidiOverrideCodepoints[key] ?? key
+
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] },
+ { type: 'keyDown', value: expectedValue },
+ { type: 'keyUp', value: expectedValue },
],
}],
})
+
+ expect(client.inputReleaseActions).to.have.been.calledWith({
+ context: autContext,
+ })
})
})
@@ -256,7 +345,7 @@ describe('key:press automation command', () => {
})
it('activates the top level context window', async () => {
- await bidiKeyPress({ key }, client as WebdriverClient, autContext, 'idSuffix')
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
expect(client.switchToWindow).to.have.been.calledWith(topLevelContext)
})
})
@@ -267,7 +356,7 @@ describe('key:press automation command', () => {
})
it('does not activate the top level context window', async () => {
- await bidiKeyPress({ key }, client as WebdriverClient, autContext, 'idSuffix')
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
expect(client.switchToWindow).not.to.have.been.called
})
})
@@ -278,28 +367,68 @@ describe('key:press automation command', () => {
})
it('activates the top level context window', async () => {
- await bidiKeyPress({ key }, client as WebdriverClient, autContext, 'idSuffix')
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
expect(client.switchToWindow).to.have.been.calledWith(topLevelContext)
})
})
- it('calls client.inputPerformActions with a keydown and keyup action', async () => {
- client.getWindowHandle.resolves(topLevelContext)
- 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: autContext,
- actions: [{
- type: 'key',
- id: 'someContextId-Tab-idSuffix',
- actions: [
- { type: 'keyDown', value: BIDI_VALUE[key] },
- { type: 'keyUp', value: BIDI_VALUE[key] },
- ],
- }],
+ describe('when supplied an overridden codepoint', () => {
+ 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(iframeElement)
+ })
+
+ for (const [key, value] of Object.entries(BidiOverrideCodepoints) as [SupportedKey, string][]) {
+ // special handling to render the source unicode instead of the rendered unicode
+ it(`dispatches a keydown and keyup action with the value '\\u${value.charCodeAt(0).toString(16).toUpperCase()}' for key '${key}'`, async () => {
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
+
+ expect(client.inputPerformActions.firstCall.args[0]).to.deep.equal({
+ context: autContext,
+ actions: [{
+ type: 'key',
+ id: `someContextId-${key}-idSuffix`,
+ actions: [
+ { type: 'keyDown', value },
+ { type: 'keyUp', value }, // in some browsers, F6 will cause the frame to lose focus, so the keyup will not be triggered
+ ],
+ }],
+ })
+
+ expect(client.inputReleaseActions).to.have.been.calledWith({
+ context: autContext,
+ })
+ })
+ }
+ })
+
+ describe('when supplied a multi-codepointutf8 key', () => {
+ const codeOne = 'e'
+ const codeTwo = '́'
+ const value = 'é'
+ let key: SupportedKey
+
+ beforeEach(() => {
+ key = toSupportedKey(value)
+ })
+
+ it('dispatches one keydown followed by a keyup event for each codepoint', async () => {
+ await bidiKeyPress(key, client, autContext, 'idSuffix')
+
+ expect(client.inputPerformActions.firstCall.args[0]).to.deep.equal({
+ context: autContext,
+ actions: [{
+ type: 'key',
+ id: `someContextId-${key}-idSuffix`,
+ actions: [
+ { type: 'keyDown', value: codeOne },
+ { type: 'keyUp', value: codeOne },
+ { type: 'keyDown', value: codeTwo },
+ { type: 'keyUp', value: codeTwo },
+ ],
+ }],
+ })
})
})
})
diff --git a/packages/types/package.json b/packages/types/package.json
index 27f6d59d23d..ee3a5a00231 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -8,7 +8,8 @@
"build-prod": "tsc || echo 'built, with type errors'",
"check-ts": "tsc --noEmit",
"clean": "rimraf src/*.js src/**/*.js",
- "lint": "eslint --ext .js,.jsx,.ts,.tsx,.json, ."
+ "lint": "eslint --ext .js,.jsx,.ts,.tsx,.json, .",
+ "test": "vitest run"
},
"dependencies": {
"semver": "^7.7.1"
@@ -20,7 +21,8 @@
"devtools-protocol": "0.0.1459876",
"express": "4.21.0",
"socket.io": "4.0.1",
- "typescript": "~5.4.5"
+ "typescript": "~5.4.5",
+ "vitest": "2.1.8"
},
"files": [
"src/*"
diff --git a/packages/types/src/automation.ts b/packages/types/src/automation.ts
index 77c7e050de1..2def55be531 100644
--- a/packages/types/src/automation.ts
+++ b/packages/types/src/automation.ts
@@ -1 +1,103 @@
+///
+
export type AutomationElementId = `${string}-string`
+
+const invalidKeyErrorKind = 'InvalidKeyError'
+
+export type SupportedNamedKey = Cypress.SupportedNamedKey
+
+export const SpaceKey = 'Space'
+
+/**
+ * Array of all supported named keys that can be used with cy.press().
+ * These are special keys that have specific meanings beyond single characters.
+ */
+export const NamedKeys: SupportedNamedKey[] = [
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'ArrowUp',
+ 'End',
+ 'Home',
+ 'PageDown',
+ 'PageUp',
+ 'Enter',
+ 'Tab',
+ 'Backspace',
+ SpaceKey,
+ 'Delete',
+ 'Insert',
+]
+
+// utility type to enable the SupportedKey union type
+enum SupportedKeyType {}
+
+/**
+ * Union type representing all keys supported by cy.press().
+ * Includes single-character strings (including unicode characters with multiple code points)
+ * and named utility keys.
+ * Must be cast to via `toSupportedKey` or guarded with `isSupportedKey`
+ * to ensure it is a valid key.
+ */
+export type SupportedKey = SupportedKeyType & string
+
+function isSingleDigitNumber (key: number | string): boolean {
+ return typeof key === 'number' && key === Math.floor(key) && key >= 0 && key <= 9
+}
+
+/**
+ * Type guard that checks if a string is a supported key for cy.press().
+ * @param key The string to check
+ * @returns True if the key is supported (single character or named key)
+ */
+export function isSupportedKey (key: string | number): key is SupportedKey {
+ if (isSingleDigitNumber(key)) {
+ return isSupportedKey(String(key))
+ }
+
+ if (!(typeof key === 'string')) {
+ return false
+ }
+
+ // Normalize the string to combine combining characters
+ const normalizedKey = key.normalize('NFC')
+
+ return (
+ // Check if it's a single grapheme cluster (user-perceived character)
+ // This handles multi-codepoint characters like emoji with modifiers
+ [...normalizedKey].length === 1 ||
+ NamedKeys.includes(key as SupportedNamedKey)
+ )
+}
+
+/**
+ * Error thrown when an unsupported key is used with cy.press().
+ * Provides information about which keys are supported.
+ */
+export class InvalidKeyError extends Error {
+ kind = invalidKeyErrorKind
+ constructor (key: string) {
+ super(`${key} is not supported by 'cy.press()'. Single-character keys are supported, as well as a selection of utility keys: ${NamedKeys.join(', ')}`)
+ }
+ static isInvalidKeyError (e: any): e is InvalidKeyError {
+ return e.kind === invalidKeyErrorKind
+ }
+}
+
+/**
+ * Converts a string to a SupportedKey, throwing an error if invalid.
+ * @param key The string key to validate and convert
+ * @returns The validated SupportedKey
+ * @throws InvalidKeyError when the key is not supported
+ */
+export function toSupportedKey (key: string | number): SupportedKey {
+ if (typeof key === 'number' && key >= 0 && key <= 9) {
+ return toSupportedKey(String(key))
+ }
+
+ if (isSupportedKey(key)) {
+ return key
+ }
+
+ throw new InvalidKeyError(String(key))
+}
diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts
index 3bcdbb39d0d..796b47827d3 100644
--- a/packages/types/src/server.ts
+++ b/packages/types/src/server.ts
@@ -4,6 +4,7 @@ import type { PlatformName } from './platform'
import type { RunModeVideoApi } from './video'
import type { ProtocolManagerShape } from './protocol'
import type Protocol from 'devtools-protocol'
+import type { SupportedKey } from './automation'
export type OpenProjectLaunchOpts = {
projectRoot: string
@@ -57,16 +58,12 @@ export interface LaunchArgs {
}
type NullableMiddlewareHook = ((message: unknown, data: unknown) => void) | null
-
-export type KeyPressSupportedKeys = 'Tab'
-
interface CommandSignature {
dataType: P
returnType: R
}
-
export interface KeyPressParams {
- key: KeyPressSupportedKeys
+ key: SupportedKey
}
export interface AutomationCommands {
diff --git a/packages/types/test/automation.spec.ts b/packages/types/test/automation.spec.ts
new file mode 100644
index 00000000000..8daca40370d
--- /dev/null
+++ b/packages/types/test/automation.spec.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest'
+import { isSupportedKey, toSupportedKey, NamedKeys } from '../src/automation'
+
+describe('automation', () => {
+ const supportedKeys = [...NamedKeys, 'a', 'b', 'c', 'd']
+ const unsupportedKeys = [10, 'some random string', -1, null, undefined, true, false, Symbol('some symbol'), new Date(), new Error(), new Function(), new RegExp(/./), new Set(), new Map(), new WeakMap(), new WeakSet()]
+
+ describe('toSupportedKey', () => {
+ describe('supported keys', () => {
+ for (const key of supportedKeys) {
+ it(`returns ${key} for ${key}`, () => {
+ expect(toSupportedKey(key)).toBe(key)
+ })
+ }
+
+ it('returns a string for a single digit number', () => {
+ expect(toSupportedKey(2)).toBe('2')
+ })
+ })
+
+ describe('unsupported keys', () => {
+ for (const key of unsupportedKeys) {
+ it(`throws an error for ${String(key)}`, () => {
+ // @ts-expect-error key is not a string
+ expect(() => toSupportedKey(key)).toThrow()
+ })
+ }
+ })
+ })
+
+ describe('isSupportedKey', () => {
+ it('should return true for supported keys', () => {
+ supportedKeys.forEach((key) => {
+ expect(isSupportedKey(key)).toBe(true)
+ })
+ })
+
+ it('returns false for unsupported keys', () => {
+ unsupportedKeys.forEach((key) => {
+ // @ts-expect-error key is not a string
+ expect(isSupportedKey(key)).toBe(false)
+ })
+ })
+ })
+})
diff --git a/yarn.lock b/yarn.lock
index 19ee10d23fc..96e83001e37 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9088,6 +9088,16 @@
test-exclude "^7.0.1"
tinyrainbow "^2.0.0"
+"@vitest/expect@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.8.tgz#13fad0e8d5a0bf0feb675dcf1d1f1a36a1773bc1"
+ integrity sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==
+ dependencies:
+ "@vitest/spy" "2.1.8"
+ "@vitest/utils" "2.1.8"
+ chai "^5.1.2"
+ tinyrainbow "^1.2.0"
+
"@vitest/expect@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.9.tgz#b566ea20d58ea6578d8dc37040d6c1a47ebe5ff8"
@@ -9109,6 +9119,15 @@
chai "^5.2.0"
tinyrainbow "^2.0.0"
+"@vitest/mocker@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.8.tgz#51dec42ac244e949d20009249e033e274e323f73"
+ integrity sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==
+ dependencies:
+ "@vitest/spy" "2.1.8"
+ estree-walker "^3.0.3"
+ magic-string "^0.30.12"
+
"@vitest/mocker@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.9.tgz#36243b27351ca8f4d0bbc4ef91594ffd2dc25ef5"
@@ -9127,7 +9146,14 @@
estree-walker "^3.0.3"
magic-string "^0.30.17"
-"@vitest/pretty-format@2.1.9", "@vitest/pretty-format@^2.1.9":
+"@vitest/pretty-format@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.8.tgz#88f47726e5d0cf4ba873d50c135b02e4395e2bca"
+ integrity sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==
+ dependencies:
+ tinyrainbow "^1.2.0"
+
+"@vitest/pretty-format@2.1.9", "@vitest/pretty-format@^2.1.8", "@vitest/pretty-format@^2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz#434ff2f7611689f9ce70cd7d567eceb883653fdf"
integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==
@@ -9141,6 +9167,14 @@
dependencies:
tinyrainbow "^2.0.0"
+"@vitest/runner@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.8.tgz#b0e2dd29ca49c25e9323ea2a45a5125d8729759f"
+ integrity sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==
+ dependencies:
+ "@vitest/utils" "2.1.8"
+ pathe "^1.1.2"
+
"@vitest/runner@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.9.tgz#cc18148d2d797fd1fd5908d1f1851d01459be2f6"
@@ -9158,6 +9192,15 @@
pathe "^2.0.3"
strip-literal "^3.0.0"
+"@vitest/snapshot@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.8.tgz#d5dc204f4b95dc8b5e468b455dfc99000047d2de"
+ integrity sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==
+ dependencies:
+ "@vitest/pretty-format" "2.1.8"
+ magic-string "^0.30.12"
+ pathe "^1.1.2"
+
"@vitest/snapshot@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.9.tgz#24260b93f798afb102e2dcbd7e61c6dfa118df91"
@@ -9176,6 +9219,13 @@
magic-string "^0.30.17"
pathe "^2.0.3"
+"@vitest/spy@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.8.tgz#bc41af3e1e6a41ae3b67e51f09724136b88fa447"
+ integrity sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==
+ dependencies:
+ tinyspy "^3.0.2"
+
"@vitest/spy@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.9.tgz#cb28538c5039d09818b8bfa8edb4043c94727c60"
@@ -9190,6 +9240,15 @@
dependencies:
tinyspy "^4.0.3"
+"@vitest/utils@2.1.8":
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.8.tgz#f8ef85525f3362ebd37fd25d268745108d6ae388"
+ integrity sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==
+ dependencies:
+ "@vitest/pretty-format" "2.1.8"
+ loupe "^3.1.2"
+ tinyrainbow "^1.2.0"
+
"@vitest/utils@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.9.tgz#4f2486de8a54acf7ecbf2c5c24ad7994a680a6c1"
@@ -32060,6 +32119,17 @@ vinyl@^3.0.0:
optionalDependencies:
fsevents "~2.3.3"
+vite-node@2.1.8:
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.8.tgz#9495ca17652f6f7f95ca7c4b568a235e0c8dbac5"
+ integrity sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==
+ dependencies:
+ cac "^6.7.14"
+ debug "^4.3.7"
+ es-module-lexer "^1.5.4"
+ pathe "^1.1.2"
+ vite "^5.0.0"
+
vite-node@2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.9.tgz#549710f76a643f1c39ef34bdb5493a944e4f895f"
@@ -32191,6 +32261,32 @@ vite@^5.0.0:
optionalDependencies:
fsevents "~2.3.3"
+vitest@2.1.8:
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.8.tgz#2e6a00bc24833574d535c96d6602fb64163092fa"
+ integrity sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==
+ dependencies:
+ "@vitest/expect" "2.1.8"
+ "@vitest/mocker" "2.1.8"
+ "@vitest/pretty-format" "^2.1.8"
+ "@vitest/runner" "2.1.8"
+ "@vitest/snapshot" "2.1.8"
+ "@vitest/spy" "2.1.8"
+ "@vitest/utils" "2.1.8"
+ chai "^5.1.2"
+ debug "^4.3.7"
+ expect-type "^1.1.0"
+ magic-string "^0.30.12"
+ pathe "^1.1.2"
+ std-env "^3.8.0"
+ tinybench "^2.9.0"
+ tinyexec "^0.3.1"
+ tinypool "^1.0.1"
+ tinyrainbow "^1.2.0"
+ vite "^5.0.0"
+ vite-node "2.1.8"
+ why-is-node-running "^2.3.0"
+
vitest@2.1.9, vitest@^2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.9.tgz#7d01ffd07a553a51c87170b5e80fea3da7fb41e7"