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

Merged
merged 22 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)?

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.

Is this about consistency between providers (like supporting ControlOrMeta both for playwright and webdriverio)?

I think it's about the compatibility with testing-library. If there are keys that are unique to providers, they can stay that way, but common keys should do the same thing and should be entered the same way like in the key map *so, ControlOrMeta is Control in every provider or something) - we can extend the keyMap, by the way

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, that sounds like a good spec. I'll check later, but providers might only support only {key} concept (like {Control}), but not physical [code] (like [ControlLeft]). Is alignment only for {key} or you're thinking about to do [code] too?

Copy link
Member

Choose a reason for hiding this comment

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

I think it's fine to fallback to Control when ControlLeft is used. There is a location property on the even though, so it might be useful for some: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location

But if there is no way to do that in provider, then we can't do anything about it yet


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

Copy link
Contributor Author

@hi-ogawa hi-ogawa Dec 23, 2024

Choose a reason for hiding this comment

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

This is broken anyways regardless of what I do here. (see test/browser/fixtures/user-event/keyboard.test.ts. on main [Shift] works differently, but [Shift] shouldn't actually work since there's no such code).

I don't think solving this is a scope of PR adding copy/cut/paste sugars, so we can follow this up separately #7118.

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",
]
`)
});
50 changes: 49 additions & 1 deletion test/browser/fixtures/user-event/keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand Down Expand Up @@ -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"`);
}
})
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