Skip to content

Commit 843a621

Browse files
authoredDec 23, 2024··
feat(browser): support clipboard api userEvent.copy, cut, paste (#6769)
1 parent de5ce3d commit 843a621

File tree

7 files changed

+228
-6
lines changed

7 files changed

+228
-6
lines changed
 

‎docs/guide/browser/interactivity-api.md

+78
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,81 @@ References:
518518

519519
- [Playwright `frame.dragAndDrop` API](https://playwright.dev/docs/api/class-frame#frame-drag-and-drop)
520520
- [WebdriverIO `element.dragAndDrop` API](https://webdriver.io/docs/api/element/dragAndDrop/)
521+
522+
## userEvent.copy
523+
524+
```ts
525+
function copy(): Promise<void>
526+
```
527+
528+
Copy the selected text to the clipboard.
529+
530+
```js
531+
import { page, userEvent } from '@vitest/browser/context'
532+
533+
test('copy and paste', async () => {
534+
// write to 'source'
535+
await userEvent.click(page.getByPlaceholder('source'))
536+
await userEvent.keyboard('hello')
537+
538+
// select and copy 'source'
539+
await userEvent.dblClick(page.getByPlaceholder('source'))
540+
await userEvent.copy()
541+
542+
// paste to 'target'
543+
await userEvent.click(page.getByPlaceholder('target'))
544+
await userEvent.paste()
545+
546+
await expect.element(page.getByPlaceholder('source')).toHaveTextContent('hello')
547+
await expect.element(page.getByPlaceholder('target')).toHaveTextContent('hello')
548+
})
549+
```
550+
551+
References:
552+
553+
- [testing-library `copy` API](https://testing-library.com/docs/user-event/convenience/#copy)
554+
555+
## userEvent.cut
556+
557+
```ts
558+
function cut(): Promise<void>
559+
```
560+
561+
Cut the selected text to the clipboard.
562+
563+
```js
564+
import { page, userEvent } from '@vitest/browser/context'
565+
566+
test('copy and paste', async () => {
567+
// write to 'source'
568+
await userEvent.click(page.getByPlaceholder('source'))
569+
await userEvent.keyboard('hello')
570+
571+
// select and cut 'source'
572+
await userEvent.dblClick(page.getByPlaceholder('source'))
573+
await userEvent.cut()
574+
575+
// paste to 'target'
576+
await userEvent.click(page.getByPlaceholder('target'))
577+
await userEvent.paste()
578+
579+
await expect.element(page.getByPlaceholder('source')).toHaveTextContent('')
580+
await expect.element(page.getByPlaceholder('target')).toHaveTextContent('hello')
581+
})
582+
```
583+
584+
References:
585+
586+
- [testing-library `cut` API](https://testing-library.com/docs/user-event/clipboard#cut)
587+
588+
## userEvent.paste
589+
590+
```ts
591+
function paste(): Promise<void>
592+
```
593+
594+
Paste the text from the clipboard. See [`userEvent.copy`](#userevent-copy) and [`userEvent.cut`](#userevent-cut) for usage examples.
595+
596+
References:
597+
598+
- [testing-library `paste` API](https://testing-library.com/docs/user-event/clipboard#paste)

‎packages/browser/context.d.ts

+21
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,27 @@ export interface UserEvent {
172172
* @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API
173173
*/
174174
upload: (element: Element | Locator, files: File | File[] | string | string[]) => Promise<void>
175+
/**
176+
* Copies the selected content.
177+
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
178+
* @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API
179+
* @see {@link https://testing-library.com/docs/user-event/clipboard#copy} testing-library API
180+
*/
181+
copy: () => Promise<void>
182+
/**
183+
* Cuts the selected content.
184+
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
185+
* @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API
186+
* @see {@link https://testing-library.com/docs/user-event/clipboard#cut} testing-library API
187+
*/
188+
cut: () => Promise<void>
189+
/**
190+
* Pastes the copied or cut content.
191+
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
192+
* @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API
193+
* @see {@link https://testing-library.com/docs/user-event/clipboard#paste} testing-library API
194+
*/
195+
paste: () => Promise<void>
175196
/**
176197
* Fills an input element with text. This will remove any existing text in the input before typing the new text.
177198
* Uses provider's API under the hood.

‎packages/browser/src/client/tester/context.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
3838
unreleased: [] as string[],
3939
}
4040

41-
return {
41+
// https://playwright.dev/docs/api/class-keyboard
42+
// https://webdriver.io/docs/api/browser/keys/
43+
const modifier = provider === `playwright`
44+
? 'ControlOrMeta'
45+
: provider === 'webdriverio'
46+
? 'Ctrl'
47+
: 'Control'
48+
49+
const userEvent: UserEvent = {
4250
setup() {
4351
return createUserEvent()
4452
},
@@ -111,11 +119,22 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
111119
keyboard.unreleased = unreleased
112120
})
113121
},
122+
async copy() {
123+
await userEvent.keyboard(`{${modifier}>}{c}{/${modifier}}`)
124+
},
125+
async cut() {
126+
await userEvent.keyboard(`{${modifier}>}{x}{/${modifier}}`)
127+
},
128+
async paste() {
129+
await userEvent.keyboard(`{${modifier}>}{v}{/${modifier}}`)
130+
},
114131
}
132+
return userEvent
115133
}
116134

117135
function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options: TestingLibraryOptions): UserEvent {
118136
let userEvent = userEventBase.setup(options)
137+
let clipboardData: DataTransfer | undefined
119138

120139
function toElement(element: Element | Locator) {
121140
return element instanceof Element ? element : element.element()
@@ -196,6 +215,16 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
196215
async keyboard(text: string) {
197216
await userEvent.keyboard(text)
198217
},
218+
219+
async copy() {
220+
clipboardData = await userEvent.copy()
221+
},
222+
async cut() {
223+
clipboardData = await userEvent.cut()
224+
},
225+
async paste() {
226+
await userEvent.paste(clipboardData)
227+
},
199228
}
200229

201230
for (const [name, fn] of Object.entries(vitestUserEvent)) {

‎packages/browser/src/node/commands/keyboard.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise
7474

7575
// fallback to insertText for non US key
7676
// https://github.com/microsoft/playwright/blob/50775698ae13642742f2a1e8983d1d686d7f192d/packages/playwright-core/src/server/input.ts#L95
77-
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'])
77+
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'])
7878

7979
export async function keyboardImplementation(
8080
pressed: Set<string>,
@@ -144,8 +144,7 @@ export async function keyboardImplementation(
144144

145145
for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) {
146146
let key = keyDef.key!
147-
const code = 'location' in keyDef ? keyDef.key! : keyDef.code!
148-
const special = Key[code as 'Shift']
147+
const special = Key[key as 'Shift']
149148

150149
if (special) {
151150
key = special
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { expect, test } from 'vitest';
2+
import { page, userEvent } from '@vitest/browser/context';
3+
4+
test('clipboard', async () => {
5+
// make it smaller since webdriverio fails when scaled
6+
page.viewport(300, 300)
7+
8+
document.body.innerHTML = `
9+
<input placeholder="first" />
10+
<input placeholder="second" />
11+
<input placeholder="third" />
12+
`;
13+
14+
// write first "hello" and copy to clipboard
15+
await userEvent.click(page.getByPlaceholder('first'));
16+
await userEvent.keyboard('hello');
17+
await userEvent.dblClick(page.getByPlaceholder('first'));
18+
await userEvent.copy();
19+
20+
// paste into second
21+
await userEvent.click(page.getByPlaceholder('second'));
22+
await userEvent.paste();
23+
24+
// append first "world" and cut
25+
await userEvent.click(page.getByPlaceholder('first'));
26+
await userEvent.keyboard('world');
27+
await userEvent.dblClick(page.getByPlaceholder('first'));
28+
await userEvent.cut();
29+
30+
// paste it to third
31+
await userEvent.click(page.getByPlaceholder('third'));
32+
await userEvent.paste();
33+
34+
expect([
35+
(page.getByPlaceholder('first').element() as any).value,
36+
(page.getByPlaceholder('second').element() as any).value,
37+
(page.getByPlaceholder('third').element() as any).value,
38+
]).toMatchInlineSnapshot(`
39+
[
40+
"",
41+
"hello",
42+
"helloworld",
43+
]
44+
`)
45+
});

‎test/browser/fixtures/user-event/keyboard.test.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest'
2-
import { userEvent, page } from '@vitest/browser/context'
2+
import { userEvent, page, server } from '@vitest/browser/context'
33

44
test('non US keys', async () => {
55
document.body.innerHTML = `
@@ -51,3 +51,51 @@ test('click with modifier', async () => {
5151
await userEvent.keyboard('{/Shift}')
5252
await expect.poll(() => el.textContent).toContain("[ok]")
5353
})
54+
55+
// TODO: https://github.com/vitest-dev/vitest/issues/7118
56+
// https://testing-library.com/docs/user-event/keyboard
57+
// https://github.com/testing-library/user-event/blob/main/src/keyboard/keyMap.ts
58+
// https://playwright.dev/docs/api/class-keyboard
59+
// https://webdriver.io/docs/api/browser/keys/
60+
test('special keys', async () => {
61+
async function testKeyboard(text: string) {
62+
let data: any;
63+
function handler(e: KeyboardEvent) {
64+
data = `${e.key}|${e.code}|${e.location}`;
65+
}
66+
document.addEventListener('keydown', handler)
67+
try {
68+
await userEvent.keyboard(text)
69+
} catch(e) {
70+
return 'ERROR';
71+
} finally {
72+
document.removeEventListener('keydown', handler)
73+
}
74+
return data
75+
}
76+
77+
if (server.provider === 'playwright') {
78+
expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
79+
expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
80+
expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"Shift|ShiftRight|2"`);
81+
expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`undefined`);
82+
expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
83+
expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
84+
}
85+
if (server.provider === 'webdriverio') {
86+
expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
87+
expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"ERROR"`);
88+
expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"ERROR"`);
89+
expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`"ERROR"`);
90+
expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
91+
expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|1"`);
92+
}
93+
if (server.provider === 'preview') {
94+
expect(await testKeyboard('{Shift}')).toMatchInlineSnapshot(`"Shift|ShiftLeft|0"`);
95+
expect(await testKeyboard('{ShiftLeft}')).toMatchInlineSnapshot(`"ShiftLeft|Unknown|0"`);
96+
expect(await testKeyboard('{ShiftRight}')).toMatchInlineSnapshot(`"ShiftRight|Unknown|0"`);
97+
expect(await testKeyboard('[Shift]')).toMatchInlineSnapshot(`"Unknown|Shift|0"`);
98+
expect(await testKeyboard('[ShiftLeft]')).toMatchInlineSnapshot(`"Shift|ShiftLeft|0"`);
99+
expect(await testKeyboard('[ShiftRight]')).toMatchInlineSnapshot(`"Shift|ShiftRight|0"`);
100+
}
101+
})

‎test/browser/specs/runner.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,16 @@ error with a stack
141141
})
142142

143143
test('user-event', async () => {
144-
const { stdout } = await runBrowserTests({
144+
const { stdout, stderr } = await runBrowserTests({
145145
root: './fixtures/user-event',
146146
})
147+
onTestFailed(() => console.error(stderr))
147148
instances.forEach(({ browser }) => {
148149
expect(stdout).toReportPassedTest('cleanup-retry.test.ts', browser)
149150
expect(stdout).toReportPassedTest('cleanup1.test.ts', browser)
150151
expect(stdout).toReportPassedTest('cleanup2.test.ts', browser)
151152
expect(stdout).toReportPassedTest('keyboard.test.ts', browser)
153+
expect(stdout).toReportPassedTest('clipboard.test.ts', browser)
152154
})
153155
})
154156

0 commit comments

Comments
 (0)
Please sign in to comment.