Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): support clipboard api userEvent.copy, cut, paste #6769

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
78 changes: 78 additions & 0 deletions docs/guide/browser/interactivity-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
```

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<void>
```

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<void>
```

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)
21 changes: 21 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/**
* 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<void>
/**
* 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<void>
/**
* 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<void>
/**
* 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.
Expand Down
31 changes: 30 additions & 1 deletion packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused why this is required here. All sent characters are from https://github.com/testing-library/user-event/blob/main/src/keyboard/keyMap.ts shouldn't we do this check in keyboard?

We probably need a test for every key 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseKeyDef doesn't strictly check what's inside {xxx} and, for example, {ControlOrMeta} ends up with keyDef: { key: 'ControlOrMeta', code: 'Unknown' }. Then we only send keyDef.key to the provider, so it's working.

We should probably do something with this key translation layer, but what do you suggest for this specific ControlOrMeta etc... specifically? Is this about consistency between providers (like supporting ControlOrMeta both for playwright and webdriverio)?


const userEvent: UserEvent = {
setup() {
return createUserEvent()
},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)) {
Expand Down
5 changes: 2 additions & 3 deletions packages/browser/src/node/commands/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
Expand Down Expand Up @@ -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']
Comment on lines -147 to -148
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this as it wasn't working when key === 'Ctrl', which is webdriverio's special key to switch modifier keys based on platform. I'm not sure the original code's intent, but it didn't break existing tests.

Copy link
Member

@sheremet-va sheremet-va Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference here is AltrRight for example - see https://github.com/testing-library/user-event/blob/main/src/keyboard/keyMap.ts. Without this, it will always trigger Alt, not AltRight

The same is for ControlLeft/ControlRight

const special = Key[key as 'Shift']

if (special) {
key = special
Expand Down
45 changes: 45 additions & 0 deletions test/browser/fixtures/user-event/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<input placeholder="first" />
<input placeholder="second" />
<input placeholder="third" />
`;

// 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",
]
`)
});
4 changes: 3 additions & 1 deletion test/browser/specs/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
Loading