diff --git a/docs/guide/browser/interactivity-api.md b/docs/guide/browser/interactivity-api.md index c41610c26dbc..c0fefe926e6e 100644 --- a/docs/guide/browser/interactivity-api.md +++ b/docs/guide/browser/interactivity-api.md @@ -518,3 +518,81 @@ References: - [Playwright `frame.dragAndDrop` API](https://playwright.dev/docs/api/class-frame#frame-drag-and-drop) - [WebdriverIO `element.dragAndDrop` API](https://webdriver.io/docs/api/element/dragAndDrop/) + +## userEvent.copy + +```ts +function copy(): Promise +``` + +Copy the selected text to the clipboard. + +```js +import { page, userEvent } from '@vitest/browser/context' + +test('copy and paste', async () => { + // write to 'source' + await userEvent.click(page.getByPlaceholder('source')) + await userEvent.keyboard('hello') + + // select and copy 'source' + await userEvent.dblClick(page.getByPlaceholder('source')) + await userEvent.copy() + + // paste to 'target' + await userEvent.click(page.getByPlaceholder('target')) + await userEvent.paste() + + await expect.element(page.getByPlaceholder('source')).toHaveTextContent('hello') + await expect.element(page.getByPlaceholder('target')).toHaveTextContent('hello') +}) +``` + +References: + +- [testing-library `copy` API](https://testing-library.com/docs/user-event/convenience/#copy) + +## userEvent.cut + +```ts +function cut(): Promise +``` + +Cut the selected text to the clipboard. + +```js +import { page, userEvent } from '@vitest/browser/context' + +test('copy and paste', async () => { + // write to 'source' + await userEvent.click(page.getByPlaceholder('source')) + await userEvent.keyboard('hello') + + // select and cut 'source' + await userEvent.dblClick(page.getByPlaceholder('source')) + await userEvent.cut() + + // paste to 'target' + await userEvent.click(page.getByPlaceholder('target')) + await userEvent.paste() + + await expect.element(page.getByPlaceholder('source')).toHaveTextContent('') + await expect.element(page.getByPlaceholder('target')).toHaveTextContent('hello') +}) +``` + +References: + +- [testing-library `cut` API](https://testing-library.com/docs/user-event/clipboard#cut) + +## userEvent.paste + +```ts +function paste(): Promise +``` + +Paste the text from the clipboard. See [`userEvent.copy`](#userevent-copy) and [`userEvent.cut`](#userevent-cut) for usage examples. + +References: + +- [testing-library `paste` API](https://testing-library.com/docs/user-event/clipboard#paste) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 866caee4a7e2..caf80089a7ba 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -172,6 +172,27 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API */ upload: (element: Element | Locator, files: File | File[] | string | string[]) => Promise + /** + * Copies the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#copy} testing-library API + */ + copy: () => Promise + /** + * Cuts the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#cut} testing-library API + */ + cut: () => Promise + /** + * Pastes the copied or cut content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#paste} testing-library API + */ + paste: () => Promise /** * Fills an input element with text. This will remove any existing text in the input before typing the new text. * Uses provider's API under the hood. diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index ee408be8c0d1..d7b7acc864de 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -38,7 +38,15 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent unreleased: [] as string[], } - return { + // https://playwright.dev/docs/api/class-keyboard + // https://webdriver.io/docs/api/browser/keys/ + const modifier = provider === `playwright` + ? 'ControlOrMeta' + : provider === 'webdriverio' + ? 'Ctrl' + : 'Control' + + const userEvent: UserEvent = { setup() { return createUserEvent() }, @@ -111,11 +119,22 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent keyboard.unreleased = unreleased }) }, + async copy() { + await userEvent.keyboard(`{${modifier}>}{c}{/${modifier}}`) + }, + async cut() { + await userEvent.keyboard(`{${modifier}>}{x}{/${modifier}}`) + }, + async paste() { + await userEvent.keyboard(`{${modifier}>}{v}{/${modifier}}`) + }, } + return userEvent } function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options: TestingLibraryOptions): UserEvent { let userEvent = userEventBase.setup(options) + let clipboardData: DataTransfer | undefined function toElement(element: Element | Locator) { return element instanceof Element ? element : element.element() @@ -196,6 +215,16 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options: async keyboard(text: string) { await userEvent.keyboard(text) }, + + async copy() { + clipboardData = await userEvent.copy() + }, + async cut() { + clipboardData = await userEvent.cut() + }, + async paste() { + await userEvent.paste(clipboardData) + }, } for (const [name, fn] of Object.entries(vitestUserEvent)) { diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index 4413260f5636..b6927370dcce 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -74,7 +74,7 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise // fallback to insertText for non US key // https://github.com/microsoft/playwright/blob/50775698ae13642742f2a1e8983d1d686d7f192d/packages/playwright-core/src/server/input.ts#L95 -const VALID_KEYS = new Set(['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'Backquote', '`', '~', 'Digit1', '1', '!', 'Digit2', '2', '@', 'Digit3', '3', '#', 'Digit4', '4', '$', 'Digit5', '5', '%', 'Digit6', '6', '^', 'Digit7', '7', '&', 'Digit8', '8', '*', 'Digit9', '9', '(', 'Digit0', '0', ')', 'Minus', '-', '_', 'Equal', '=', '+', 'Backslash', '\\', '|', 'Backspace', 'Tab', 'KeyQ', 'q', 'Q', 'KeyW', 'w', 'W', 'KeyE', 'e', 'E', 'KeyR', 'r', 'R', 'KeyT', 't', 'T', 'KeyY', 'y', 'Y', 'KeyU', 'u', 'U', 'KeyI', 'i', 'I', 'KeyO', 'o', 'O', 'KeyP', 'p', 'P', 'BracketLeft', '[', '{', 'BracketRight', ']', '}', 'CapsLock', 'KeyA', 'a', 'A', 'KeyS', 's', 'S', 'KeyD', 'd', 'D', 'KeyF', 'f', 'F', 'KeyG', 'g', 'G', 'KeyH', 'h', 'H', 'KeyJ', 'j', 'J', 'KeyK', 'k', 'K', 'KeyL', 'l', 'L', 'Semicolon', ';', ':', 'Quote', '\'', '"', 'Enter', '\n', '\r', 'ShiftLeft', 'Shift', 'KeyZ', 'z', 'Z', 'KeyX', 'x', 'X', 'KeyC', 'c', 'C', 'KeyV', 'v', 'V', 'KeyB', 'b', 'B', 'KeyN', 'n', 'N', 'KeyM', 'm', 'M', 'Comma', ',', '<', 'Period', '.', '>', 'Slash', '/', '?', 'ShiftRight', 'ControlLeft', 'Control', 'MetaLeft', 'Meta', 'AltLeft', 'Alt', 'Space', ' ', 'AltRight', 'AltGraph', 'MetaRight', 'ContextMenu', 'ControlRight', 'PrintScreen', 'ScrollLock', 'Pause', 'PageUp', 'PageDown', 'Insert', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'NumLock', 'NumpadDivide', 'NumpadMultiply', 'NumpadSubtract', 'Numpad7', 'Numpad8', 'Numpad9', 'Numpad4', 'Numpad5', 'Numpad6', 'NumpadAdd', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad0', 'NumpadDecimal', 'NumpadEnter']) +const VALID_KEYS = new Set(['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'Backquote', '`', '~', 'Digit1', '1', '!', 'Digit2', '2', '@', 'Digit3', '3', '#', 'Digit4', '4', '$', 'Digit5', '5', '%', 'Digit6', '6', '^', 'Digit7', '7', '&', 'Digit8', '8', '*', 'Digit9', '9', '(', 'Digit0', '0', ')', 'Minus', '-', '_', 'Equal', '=', '+', 'Backslash', '\\', '|', 'Backspace', 'Tab', 'KeyQ', 'q', 'Q', 'KeyW', 'w', 'W', 'KeyE', 'e', 'E', 'KeyR', 'r', 'R', 'KeyT', 't', 'T', 'KeyY', 'y', 'Y', 'KeyU', 'u', 'U', 'KeyI', 'i', 'I', 'KeyO', 'o', 'O', 'KeyP', 'p', 'P', 'BracketLeft', '[', '{', 'BracketRight', ']', '}', 'CapsLock', 'KeyA', 'a', 'A', 'KeyS', 's', 'S', 'KeyD', 'd', 'D', 'KeyF', 'f', 'F', 'KeyG', 'g', 'G', 'KeyH', 'h', 'H', 'KeyJ', 'j', 'J', 'KeyK', 'k', 'K', 'KeyL', 'l', 'L', 'Semicolon', ';', ':', 'Quote', '\'', '"', 'Enter', '\n', '\r', 'ShiftLeft', 'Shift', 'KeyZ', 'z', 'Z', 'KeyX', 'x', 'X', 'KeyC', 'c', 'C', 'KeyV', 'v', 'V', 'KeyB', 'b', 'B', 'KeyN', 'n', 'N', 'KeyM', 'm', 'M', 'Comma', ',', '<', 'Period', '.', '>', 'Slash', '/', '?', 'ShiftRight', 'ControlLeft', 'Control', 'MetaLeft', 'Meta', 'AltLeft', 'Alt', 'Space', ' ', 'AltRight', 'AltGraph', 'MetaRight', 'ContextMenu', 'ControlRight', 'PrintScreen', 'ScrollLock', 'Pause', 'PageUp', 'PageDown', 'Insert', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'NumLock', 'NumpadDivide', 'NumpadMultiply', 'NumpadSubtract', 'Numpad7', 'Numpad8', 'Numpad9', 'Numpad4', 'Numpad5', 'Numpad6', 'NumpadAdd', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad0', 'NumpadDecimal', 'NumpadEnter', 'ControlOrMeta']) export async function keyboardImplementation( pressed: Set, @@ -144,8 +144,7 @@ export async function keyboardImplementation( for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { let key = keyDef.key! - const code = 'location' in keyDef ? keyDef.key! : keyDef.code! - const special = Key[code as 'Shift'] + const special = Key[key as 'Shift'] if (special) { key = special diff --git a/test/browser/fixtures/user-event/clipboard.test.ts b/test/browser/fixtures/user-event/clipboard.test.ts new file mode 100644 index 000000000000..18f286a7ed29 --- /dev/null +++ b/test/browser/fixtures/user-event/clipboard.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; +import { page, userEvent } from '@vitest/browser/context'; + +test('clipboard', async () => { + // make it smaller since webdriverio fails when scaled + page.viewport(300, 300) + + document.body.innerHTML = ` + + + + `; + + // write first "hello" and copy to clipboard + await userEvent.click(page.getByPlaceholder('first')); + await userEvent.keyboard('hello'); + await userEvent.dblClick(page.getByPlaceholder('first')); + await userEvent.copy(); + + // paste into second + await userEvent.click(page.getByPlaceholder('second')); + await userEvent.paste(); + + // append first "world" and cut + await userEvent.click(page.getByPlaceholder('first')); + await userEvent.keyboard('world'); + await userEvent.dblClick(page.getByPlaceholder('first')); + await userEvent.cut(); + + // paste it to third + await userEvent.click(page.getByPlaceholder('third')); + await userEvent.paste(); + + expect([ + (page.getByPlaceholder('first').element() as any).value, + (page.getByPlaceholder('second').element() as any).value, + (page.getByPlaceholder('third').element() as any).value, + ]).toMatchInlineSnapshot(` + [ + "", + "hello", + "helloworld", + ] + `) +}); diff --git a/test/browser/fixtures/user-event/keyboard.test.ts b/test/browser/fixtures/user-event/keyboard.test.ts index 90541b433e67..e1314d03cc74 100644 --- a/test/browser/fixtures/user-event/keyboard.test.ts +++ b/test/browser/fixtures/user-event/keyboard.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { userEvent, page } from '@vitest/browser/context' +import { userEvent, page, server } from '@vitest/browser/context' test('non US keys', async () => { document.body.innerHTML = ` @@ -51,3 +51,51 @@ test('click with modifier', async () => { await userEvent.keyboard('{/Shift}') await expect.poll(() => el.textContent).toContain("[ok]") }) + +// TODO: https://github.com/vitest-dev/vitest/issues/7118 +// https://testing-library.com/docs/user-event/keyboard +// https://github.com/testing-library/user-event/blob/main/src/keyboard/keyMap.ts +// https://playwright.dev/docs/api/class-keyboard +// https://webdriver.io/docs/api/browser/keys/ +test('special keys', async () => { + async function testKeyboard(text: string) { + let data: any; + function handler(e: KeyboardEvent) { + data = `${e.key}|${e.code}|${e.location}`; + } + document.addEventListener('keydown', handler) + try { + await userEvent.keyboard(text) + } catch(e) { + return 'ERROR'; + } finally { + document.removeEventListener('keydown', handler) + } + return data + } + + if (server.provider === 'playwright') { + expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"Shift|ShiftRight|2"`); + expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`undefined`); + expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + } + if (server.provider === 'webdriverio') { + expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"ERROR"`); + expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"ERROR"`); + expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`"ERROR"`); + expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`); + } + if (server.provider === 'preview') { + expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|0"`); + expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"ShiftLeft|Unknown|0"`); + expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"ShiftRight|Unknown|0"`); + expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`"Unknown|Shift|0"`); + expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|0"`); + expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftRight|0"`); + } +}) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 64dfaafdb0e2..bcbc95e825e9 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -141,14 +141,16 @@ error with a stack }) test('user-event', async () => { - const { stdout } = await runBrowserTests({ + const { stdout, stderr } = await runBrowserTests({ root: './fixtures/user-event', }) + onTestFailed(() => console.error(stderr)) instances.forEach(({ browser }) => { expect(stdout).toReportPassedTest('cleanup-retry.test.ts', browser) expect(stdout).toReportPassedTest('cleanup1.test.ts', browser) expect(stdout).toReportPassedTest('cleanup2.test.ts', browser) expect(stdout).toReportPassedTest('keyboard.test.ts', browser) + expect(stdout).toReportPassedTest('clipboard.test.ts', browser) }) })