From 00ae8a7db206bfae67b82fc6a86f3e4d967968ab Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 14:55:29 +0200 Subject: [PATCH 01/28] feat: implement some userEvent methods: type/clear, add fill event --- packages/browser/context.d.ts | 27 +++++++ packages/browser/src/client/context.ts | 18 +++-- packages/browser/src/node/commands/clear.ts | 24 +++++++ packages/browser/src/node/commands/fill.ts | 25 +++++++ packages/browser/src/node/commands/index.ts | 12 +++- packages/browser/src/node/commands/type.ts | 71 +++++++++++++++++++ .../browser/src/node/plugins/pluginContext.ts | 23 ++++-- test/browser/test/dom.test.ts | 60 +++++++++++++++- 8 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 packages/browser/src/node/commands/clear.ts create mode 100644 packages/browser/src/node/commands/fill.ts create mode 100644 packages/browser/src/node/commands/type.ts diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 7145cc264b86..a4515469ab54 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -68,12 +68,39 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API */ click: (element: Element, options?: UserEventClickOptions) => Promise + /** + * Types text into an element. Uses provider's API under the hood. + * Supports [user-event syntax](https://testing-library.com/docs/user-event/keyboard) even with `playwright` and `webdriverio` providers. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + type: (element: Element, text: string, options?: UserEventTypeOptions) => Promise + /** + * Removes all text from an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API + * @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API + */ + clear: (element: Element) => 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. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API + * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + fill: (element: Element, text: string) => Promise } export interface UserEventClickOptions { [key: string]: any } +export interface UserEventTypeOptions { + [key: string]: any +} + type Platform = | 'aix' | 'android' diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 776a704517e0..03083a672e0d 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -1,9 +1,5 @@ import type { Task, WorkerGlobalState } from 'vitest' -import type { - BrowserPage, - UserEvent, - UserEventClickOptions, -} from '../../context' +import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTypeOptions } from '../../context' import type { BrowserRPC } from './client' import type { BrowserRunnerState } from './utils' @@ -67,6 +63,18 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_click', xpath, options) }, + type(element: Element, text: string, options: UserEventTypeOptions = {}) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_type', xpath, text, options) + }, + clear(element: Element) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_clear', xpath) + }, + fill(element: Element, text: string) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_fill', xpath, text) + }, } const screenshotIds: Record> = {} diff --git a/packages/browser/src/node/commands/clear.ts b/packages/browser/src/node/commands/clear.ts new file mode 100644 index 000000000000..bf780fc3b8bf --- /dev/null +++ b/packages/browser/src/node/commands/clear.ts @@ -0,0 +1,24 @@ +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const clear: UserEventCommand = async ( + context, + xpath, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + const { tester } = context + const element = tester.locator(`xpath=${xpath}`) + await element.clear() + } + else if (context.provider instanceof WebdriverBrowserProvider) { + const browser = context.browser + const markedXpath = `//${xpath}` + const element = await browser.$(markedXpath) + await element.clearValue() + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`) + } +} diff --git a/packages/browser/src/node/commands/fill.ts b/packages/browser/src/node/commands/fill.ts new file mode 100644 index 000000000000..4e6966764670 --- /dev/null +++ b/packages/browser/src/node/commands/fill.ts @@ -0,0 +1,25 @@ +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const fill: UserEventCommand = async ( + context, + xpath, + text, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + const { tester } = context + const element = tester.locator(`xpath=${xpath}`) + await element.fill(text) + } + else if (context.provider instanceof WebdriverBrowserProvider) { + const browser = context.browser + const markedXpath = `//${xpath}` + const element = await browser.$(markedXpath) + await element.setValue(text) + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`) + } +} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index a38fb2f2ecdc..c3d26c719d14 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -1,5 +1,12 @@ import { click } from './click' -import { readFile, removeFile, writeFile } from './fs' +import { type } from './type' +import { clear } from './clear' +import { fill } from './fill' +import { + readFile, + removeFile, + writeFile, +} from './fs' import { sendKeys } from './keyboard' import { screenshot } from './screenshot' @@ -10,4 +17,7 @@ export default { sendKeys, __vitest_click: click, __vitest_screenshot: screenshot, + __vitest_type: type, + __vitest_clear: clear, + __vitest_fill: fill, } diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts new file mode 100644 index 000000000000..80ce03b34586 --- /dev/null +++ b/packages/browser/src/node/commands/type.ts @@ -0,0 +1,71 @@ +import { parseKeyDef } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' +import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const type: UserEventCommand = async ( + context, + xpath, + text, + _options = {}, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + const { tester } = context + const element = tester.locator(`xpath=${xpath}`) + const actions = parseKeyDef(defaultKeyMap, text) + + for (const { releasePrevious, repeat, keyDef } of actions) { + const key = keyDef.key! + + if (!releasePrevious) { + for (let i = 1; i <= repeat; i++) { + await element.press(key) + } + } + } + } + else if (context.provider instanceof WebdriverBrowserProvider) { + const { Key } = await import('webdriverio') + const browser = context.browser + const markedXpath = `//${xpath}` + const element = await browser.$(markedXpath) + const actions = parseKeyDef(defaultKeyMap, text) + + if (!await element.isFocused()) { + await element.click() + } + + const keys = actions.reduce((acc, { keyDef, repeat, releasePrevious }) => { + const key = keyDef.key! + const code = 'location' in keyDef ? keyDef.key! : keyDef.code! + const special = Key[code as 'Shift'] + if (special) { + const specialArray = [special] + Object.assign(specialArray, { special: true }) + acc.push(specialArray) + } + else { + if (releasePrevious) + return acc + const last = acc[acc.length - 1] + const value = key.repeat(repeat) + if (last && !('special' in last)) { + last.push(value) + } + else { + acc.push([value]) + } + } + return acc + }, []) + + for (const key of keys) { + await browser.keys(key.join('')) + } + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support typing`) + } +} diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 108dc581de1c..42439b4c071a 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -81,17 +81,26 @@ export const server = { } } export const commands = server.commands -export const userEvent = ${ - provider.name === 'preview' ? '__vitest_user_event__' : '__userEvent_CDP__' - } +export const userEvent = ${getUserEvent(provider)} export { page } ` } -async function getUserEventImport( - provider: BrowserProvider, - resolve: (id: string, importer: string) => Promise, -) { +function getUserEvent(provider: BrowserProvider) { + if (provider.name !== 'preview') { + return '__userEvent_CDP__' + } + // TODO: have this in a separate file + return `{ + ...__vitest_user_event__, + fill: async (element, text) => { + await __vitest_user_event__.clear(element) + await __vitest_user_event__.type(element, text) + } +}` +} + +async function getUserEventImport(provider: BrowserProvider, resolve: (id: string, importer: string) => Promise) { if (provider.name !== 'preview') { return '' } diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 0eb2f150540c..534bd18c8ac1 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from 'vitest' -import { page } from '@vitest/browser/context' +import { beforeEach, describe, expect, test } from 'vitest' +import { page, userEvent } from '@vitest/browser/context' import { createNode } from '#src/createNode' import '../src/button.css' describe('dom related activity', () => { + beforeEach(() => { + document.body.replaceChildren() + }) + test('renders div', async () => { document.body.style.background = '#f3f3f3' const wrapper = document.createElement('div') @@ -17,4 +21,56 @@ describe('dom related activity', () => { }) expect(screenshotPath).toMatch(/__screenshots__\/dom.test.ts\/dom-related-activity-renders-div-1.png/) }) + + test.only('types into an input', async () => { + const input = document.createElement('input') + input.type = 'text' + input.placeholder = 'Type here' + const pressed: string[] = [] + input.addEventListener('keydown', (event) => { + pressed.push(event.key) + }) + document.body.appendChild(input) + await userEvent.type(input, 'Hello World!') + expect(input.value).toBe('Hello World!') + + await userEvent.type(input, '{a>3}4') + expect(input.value).toBe('Hello World!aaa4') + + await userEvent.type(input, '{backspace}') + expect(input.value).toBe('Hello World!aaa') + + // doesn't affect the input value + await userEvent.type(input, '{/a}') + expect(input.value).toBe('Hello World!aaa') + + expect(pressed).toEqual([ + 'H', + 'e', + 'l', + 'l', + 'o', + ' ', + 'W', + 'o', + 'r', + 'l', + 'd', + '!', + 'a', + 'a', + 'a', + '4', + 'Backspace', + ]) + + await userEvent.type(input, '{Shift}b{/Shift}') + + // this follow userEvent logic + expect(input.value).toBe('Hello World!aaab') + + await userEvent.clear(input) + + expect(input.value).toBe('') + }) }) From 439ab2cc3cffaa66c4057d581f9c4e4afec31076 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 14:58:49 +0200 Subject: [PATCH 02/28] docs: more docs --- packages/browser/context.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index a4515469ab54..9390dd9728ff 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -70,7 +70,7 @@ export interface UserEvent { click: (element: Element, options?: UserEventClickOptions) => Promise /** * Types text into an element. Uses provider's API under the hood. - * Supports [user-event syntax](https://testing-library.com/docs/user-event/keyboard) even with `playwright` and `webdriverio` providers. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API @@ -85,6 +85,7 @@ export interface UserEvent { clear: (element: Element) => Promise /** * Fills an input element with text. This will remove any existing text in the input before typing the new text. + * This method **doesn't support** [user-uvent `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). * Uses provider's API under the hood. * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API From 51bc642b0678ca3a619335f192988be6f4a11695 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 15:05:22 +0200 Subject: [PATCH 03/28] chore: remove only --- test/browser/test/dom.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 534bd18c8ac1..507d90376b47 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -22,7 +22,7 @@ describe('dom related activity', () => { expect(screenshotPath).toMatch(/__screenshots__\/dom.test.ts\/dom-related-activity-renders-div-1.png/) }) - test.only('types into an input', async () => { + test('types into an input', async () => { const input = document.createElement('input') input.type = 'text' input.placeholder = 'Type here' From 108bc4b6c455b3c4f877f71d9ae835439886fe90 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 15:24:30 +0200 Subject: [PATCH 04/28] feat: support selectall --- packages/browser/src/node/commands/type.ts | 24 +++++++++++++++++++++- test/browser/test/dom.test.ts | 11 ++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts index 80ce03b34586..e8f7cde3c52d 100644 --- a/packages/browser/src/node/commands/type.ts +++ b/packages/browser/src/node/commands/type.ts @@ -21,7 +21,12 @@ export const type: UserEventCommand = async ( if (!releasePrevious) { for (let i = 1; i <= repeat; i++) { - await element.press(key) + if (key === 'selectall') { + await element.selectText() + } + else { + await element.press(key) + } } } } @@ -41,6 +46,13 @@ export const type: UserEventCommand = async ( const key = keyDef.key! const code = 'location' in keyDef ? keyDef.key! : keyDef.code! const special = Key[code as 'Shift'] + if (code === 'Unknown' && key === 'selectall') { + const specialArray = ['selectall'] + Object.assign(specialArray, { special: true }) + acc.push(specialArray) + return acc + } + if (special) { const specialArray = [special] Object.assign(specialArray, { special: true }) @@ -62,6 +74,16 @@ export const type: UserEventCommand = async ( }, []) for (const key of keys) { + if (key[0] === 'selectall') { + await browser.execute(() => { + const element = document.activeElement as HTMLInputElement + if (element) { + element.select() + } + }) + continue + } + await browser.keys(key.join('')) } } diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 507d90376b47..d4c3ba8f61dc 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -73,4 +73,15 @@ describe('dom related activity', () => { expect(input.value).toBe('') }) + + test('selectall works correctly', async () => { + const input = document.createElement('input') + input.type = 'text' + input.placeholder = 'Type here' + document.body.appendChild(input) + await userEvent.type(input, 'Hello World!') + await userEvent.type(input, '{selectall}') + await userEvent.type(input, '{backspace}') + expect(input.value).toBe('') + }) }) From 86171023a94c388c4ea198e8dec3a19c9076f514 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 15:46:06 +0200 Subject: [PATCH 05/28] feat: support tab method --- packages/browser/context.d.ts | 13 ++++++++++++ packages/browser/src/client/context.ts | 5 ++++- packages/browser/src/node/commands/index.ts | 2 ++ packages/browser/src/node/commands/tab.ts | 23 +++++++++++++++++++++ test/browser/test/dom.test.ts | 18 ++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/browser/src/node/commands/tab.ts diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 9390dd9728ff..3a233a1149c6 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -83,6 +83,13 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API */ clear: (element: Element) => Promise + /** + * Sends a `Tab` key event. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API + */ + tab: (options?: UserEventTabOptions) => Promise /** * Fills an input element with text. This will remove any existing text in the input before typing the new text. * This method **doesn't support** [user-uvent `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). @@ -98,7 +105,13 @@ export interface UserEventClickOptions { [key: string]: any } +export interface UserEventTabOptions { + shift?: boolean + [key: string]: any +} + export interface UserEventTypeOptions { + skipClick?: boolean [key: string]: any } diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 03083a672e0d..0730f636eadb 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -1,5 +1,5 @@ import type { Task, WorkerGlobalState } from 'vitest' -import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTypeOptions } from '../../context' +import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTabOptions, UserEventTypeOptions } from '../../context' import type { BrowserRPC } from './client' import type { BrowserRunnerState } from './utils' @@ -75,6 +75,9 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_fill', xpath, text) }, + tab(options: UserEventTabOptions = {}) { + return triggerCommand('__vitest_tab', options) + }, } const screenshotIds: Record> = {} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index c3d26c719d14..b28435e5d0fa 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -2,6 +2,7 @@ import { click } from './click' import { type } from './type' import { clear } from './clear' import { fill } from './fill' +import { tab } from './tab' import { readFile, removeFile, @@ -20,4 +21,5 @@ export default { __vitest_type: type, __vitest_clear: clear, __vitest_fill: fill, + __vitest_tab: tab, } diff --git a/packages/browser/src/node/commands/tab.ts b/packages/browser/src/node/commands/tab.ts new file mode 100644 index 000000000000..bb23c9c8e7db --- /dev/null +++ b/packages/browser/src/node/commands/tab.ts @@ -0,0 +1,23 @@ +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const tab: UserEventCommand = async ( + context, + options = {}, +) => { + const provider = context.provider + if (provider instanceof PlaywrightBrowserProvider) { + const page = context.page + await page.keyboard.press(options.shift === true ? 'Shift+Tab' : 'Tab') + return + } + if (provider instanceof WebdriverBrowserProvider) { + const { Key } = await import('webdriverio') + const browser = context.browser + await browser.keys(options.shift === true ? [Key.Shift, Key.Tab] : [Key.Tab]) + return + } + throw new Error(`Provider "${provider.name}" doesn't support tab command`) +} diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index d4c3ba8f61dc..c48b32fff739 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -84,4 +84,22 @@ describe('dom related activity', () => { await userEvent.type(input, '{backspace}') expect(input.value).toBe('') }) + + test('tab works correctly', async () => { + const input1 = document.createElement('input') + input1.type = 'text' + const input2 = document.createElement('input') + input2.type = 'text' + document.body.appendChild(input1) + document.body.appendChild(input2) + + input1.focus() + await userEvent.tab() + + expect(document.activeElement).toBe(input2) + + await userEvent.tab({ shift: true }) + + expect(document.activeElement).toBe(input1) + }) }) From b20b4d202956bf82e35cc6e5a3e91a041c8c494c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 16:01:43 +0200 Subject: [PATCH 06/28] chore: logic closer to user-event --- packages/browser/context.d.ts | 1 + packages/browser/src/node/commands/type.ts | 46 +++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 3a233a1149c6..11fc19521e57 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -112,6 +112,7 @@ export interface UserEventTabOptions { export interface UserEventTypeOptions { skipClick?: boolean + skipAutoClose?: boolean [key: string]: any } diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts index e8f7cde3c52d..f68e35423fd6 100644 --- a/packages/browser/src/node/commands/type.ts +++ b/packages/browser/src/node/commands/type.ts @@ -9,27 +9,53 @@ export const type: UserEventCommand = async ( context, xpath, text, - _options = {}, + options = {}, ) => { + const { skipClick = false, skipAutoClose = false } = options + if (context.provider instanceof PlaywrightBrowserProvider) { - const { tester } = context + const { tester, page } = context const element = tester.locator(`xpath=${xpath}`) const actions = parseKeyDef(defaultKeyMap, text) - for (const { releasePrevious, repeat, keyDef } of actions) { + if (!skipClick) { + await element.focus() + } + + const pressed = new Set() + + for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { const key = keyDef.key! + if (pressed.has(key)) { + await page.keyboard.up(key) + pressed.delete(key) + } + if (!releasePrevious) { + if (key === 'selectall') { + await element.selectText() + continue + } + for (let i = 1; i <= repeat; i++) { - if (key === 'selectall') { - await element.selectText() - } - else { - await element.press(key) - } + await page.keyboard.down(key) + } + + if (releaseSelf) { + await page.keyboard.up(key) + } + else { + pressed.add(key) } } } + + if (!skipAutoClose) { + for (const key of pressed) { + await page.keyboard.up(key) + } + } } else if (context.provider instanceof WebdriverBrowserProvider) { const { Key } = await import('webdriverio') @@ -38,7 +64,7 @@ export const type: UserEventCommand = async ( const element = await browser.$(markedXpath) const actions = parseKeyDef(defaultKeyMap, text) - if (!await element.isFocused()) { + if (!skipClick && !await element.isFocused()) { await element.click() } From 8112c45c353fa29c7c5795e92c3c0bffdc7ec5d6 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 16:02:58 +0200 Subject: [PATCH 07/28] chore: rename keyboard to sendKeys --- packages/browser/src/node/commands/index.ts | 2 +- packages/browser/src/node/commands/{keyboard.ts => sendKeys.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/browser/src/node/commands/{keyboard.ts => sendKeys.ts} (100%) diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index b28435e5d0fa..ba8fe0ecbab5 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -8,7 +8,7 @@ import { removeFile, writeFile, } from './fs' -import { sendKeys } from './keyboard' +import { sendKeys } from './sendKeys' import { screenshot } from './screenshot' export default { diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/sendKeys.ts similarity index 100% rename from packages/browser/src/node/commands/keyboard.ts rename to packages/browser/src/node/commands/sendKeys.ts From ad4808aaa835578ef96da3b0f7c34242aa172b16 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 16:37:10 +0200 Subject: [PATCH 08/28] chore: remove tester/body from playwright context, add only iframe, implement keyboard support --- packages/browser/context.d.ts | 23 ++- packages/browser/providers/playwright.d.ts | 6 +- packages/browser/src/client/context.ts | 3 + packages/browser/src/client/orchestrator.ts | 1 + packages/browser/src/node/commands/clear.ts | 4 +- packages/browser/src/node/commands/click.ts | 2 +- packages/browser/src/node/commands/fill.ts | 4 +- packages/browser/src/node/commands/index.ts | 2 + .../browser/src/node/commands/keyboard.ts | 133 ++++++++++++++++++ .../browser/src/node/commands/screenshot.ts | 4 +- packages/browser/src/node/commands/type.ts | 97 +++---------- .../browser/src/node/providers/playwright.ts | 6 +- 12 files changed, 191 insertions(+), 94 deletions(-) create mode 100644 packages/browser/src/node/commands/keyboard.ts diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 11fc19521e57..314eef3ab3d7 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -68,9 +68,26 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API */ click: (element: Element, options?: UserEventClickOptions) => Promise + /** + * Type text on the keyboard. If any input is focused, it will receive the text, + * otherwise it will be typed on the document. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.keyboard('foo') // translates to: f, o, o + * await userEvent.keyboard('{{a[[') // translates to: {, a, [ + * await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API + */ + keyboard: (text: string) => Promise /** * Types text into an element. Uses provider's API under the hood. * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.type(input, 'foo') // translates to: f, o, o + * await userEvent.type(input, '{{a[[') // translates to: {, a, [ + * await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API @@ -92,8 +109,12 @@ export interface UserEvent { tab: (options?: UserEventTabOptions) => Promise /** * Fills an input element with text. This will remove any existing text in the input before typing the new text. - * This method **doesn't support** [user-uvent `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). * Uses provider's API under the hood. + * This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). + * @example + * await userEvent.fill(input, 'foo') // translates to: f, o, o + * await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [ + * await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, } * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index ed58fc698bce..e13b70884db3 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -1,8 +1,7 @@ import type { BrowserContextOptions, - FrameLocator, + Frame, LaunchOptions, - Locator, Page, } from 'playwright' @@ -17,7 +16,6 @@ declare module 'vitest/node' { export interface BrowserCommandContext { page: Page - tester: FrameLocator - body: Locator + frame: Frame } } diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 0730f636eadb..075bdb05a7ef 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -78,6 +78,9 @@ export const userEvent: UserEvent = { tab(options: UserEventTabOptions = {}) { return triggerCommand('__vitest_tab', options) }, + keyboard(text: string) { + return triggerCommand('__vitest_keyboard', text) + }, } const screenshotIds: Record> = {} diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 9b1f0f9d8e3a..c6c33c4be9ca 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -52,6 +52,7 @@ function createIframe(container: HTMLDivElement, file: string) { iframe.style.position = 'relative' iframe.setAttribute('allowfullscreen', 'true') iframe.setAttribute('allow', 'clipboard-write;') + iframe.setAttribute('name', 'vitest-iframe') iframes.set(file, iframe) container.appendChild(iframe) diff --git a/packages/browser/src/node/commands/clear.ts b/packages/browser/src/node/commands/clear.ts index bf780fc3b8bf..df8a34994e36 100644 --- a/packages/browser/src/node/commands/clear.ts +++ b/packages/browser/src/node/commands/clear.ts @@ -8,8 +8,8 @@ export const clear: UserEventCommand = async ( xpath, ) => { if (context.provider instanceof PlaywrightBrowserProvider) { - const { tester } = context - const element = tester.locator(`xpath=${xpath}`) + const { frame } = context + const element = frame.locator(`xpath=${xpath}`) await element.clear() } else if (context.provider instanceof WebdriverBrowserProvider) { diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts index 5d93e34ca715..c2a3266f1936 100644 --- a/packages/browser/src/node/commands/click.ts +++ b/packages/browser/src/node/commands/click.ts @@ -10,7 +10,7 @@ export const click: UserEventCommand = async ( ) => { const provider = context.provider if (provider instanceof PlaywrightBrowserProvider) { - const tester = context.tester + const tester = context.frame await tester.locator(`xpath=${xpath}`).click(options) return } diff --git a/packages/browser/src/node/commands/fill.ts b/packages/browser/src/node/commands/fill.ts index 4e6966764670..25ad56bbeead 100644 --- a/packages/browser/src/node/commands/fill.ts +++ b/packages/browser/src/node/commands/fill.ts @@ -9,8 +9,8 @@ export const fill: UserEventCommand = async ( text, ) => { if (context.provider instanceof PlaywrightBrowserProvider) { - const { tester } = context - const element = tester.locator(`xpath=${xpath}`) + const { frame } = context + const element = frame.locator(`xpath=${xpath}`) await element.fill(text) } else if (context.provider instanceof WebdriverBrowserProvider) { diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index ba8fe0ecbab5..1a7503898c24 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -3,6 +3,7 @@ import { type } from './type' import { clear } from './clear' import { fill } from './fill' import { tab } from './tab' +import { keyboard } from './keyboard' import { readFile, removeFile, @@ -22,4 +23,5 @@ export default { __vitest_clear: clear, __vitest_fill: fill, __vitest_tab: tab, + __vitest_keyboard: keyboard, } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts new file mode 100644 index 000000000000..c7b7cfbad3ce --- /dev/null +++ b/packages/browser/src/node/commands/keyboard.ts @@ -0,0 +1,133 @@ +import { parseKeyDef } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' +import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' +import type { BrowserProvider } from 'vitest/node' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEvent } from '../../../context' +import type { UserEventCommand } from './utils' + +export const keyboard: UserEventCommand = async ( + context, + text, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + await context.frame.focus('body') + } + else if (context.project instanceof WebdriverBrowserProvider) { + const body = await context.browser.$('body') + await body.click({ y: 0, x: 0 }) // TODO: use actual focus + } + + await keyboardImplementation( + context.provider, + context.contextId, + text, + async () => { + function selectAll() { + const element = document.activeElement as HTMLInputElement + if (element && element.select) { + element.select() + } + } + if (context.provider instanceof PlaywrightBrowserProvider) { + await context.page.evaluate(selectAll) + } + else if (context.provider instanceof WebdriverBrowserProvider) { + await context.browser.execute(selectAll) + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support selecting all text`) + } + }, + ) +} + +export async function keyboardImplementation( + provider: BrowserProvider, + contextId: string, + text: string, + selectAll: () => Promise, +) { + const pressed = new Set() + + if (provider instanceof PlaywrightBrowserProvider) { + const page = provider.getPage(contextId) + const actions = parseKeyDef(defaultKeyMap, text) + + for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { + const key = keyDef.key! + + if (pressed.has(key)) { + await page.keyboard.up(key) + pressed.delete(key) + } + + if (!releasePrevious) { + if (key === 'selectall') { + await selectAll() + continue + } + + for (let i = 1; i <= repeat; i++) { + await page.keyboard.down(key) + } + + if (releaseSelf) { + await page.keyboard.up(key) + } + else { + pressed.add(key) + } + } + } + } + else if (provider instanceof WebdriverBrowserProvider) { + const { Key } = await import('webdriverio') + const browser = provider.browser! + const actions = parseKeyDef(defaultKeyMap, text) + + const keys = actions.reduce((acc, { keyDef, repeat, releasePrevious }) => { + const key = keyDef.key! + const code = 'location' in keyDef ? keyDef.key! : keyDef.code! + const special = Key[code as 'Shift'] + if (code === 'Unknown' && key === 'selectall') { + const specialArray = ['selectall'] + Object.assign(specialArray, { special: true }) + acc.push(specialArray) + return acc + } + + if (special) { + const specialArray = [special] + Object.assign(specialArray, { special: true }) + acc.push(specialArray) + } + else { + if (releasePrevious) + return acc + const last = acc[acc.length - 1] + const value = key.repeat(repeat) + if (last && !('special' in last)) { + last.push(value) + } + else { + acc.push([value]) + } + } + return acc + }, []) + + for (const key of keys) { + if (key[0] === 'selectall') { + await selectAll() + continue + } + + await browser.keys(key.join('')) + } + } + + return { + pressed, + } +} diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 33971b92a0ad..b9371b47c618 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -28,12 +28,12 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async ( if (context.provider instanceof PlaywrightBrowserProvider) { if (options.element) { const { element: elementXpath, ...config } = options - const iframe = context.tester + const iframe = context.frame const element = iframe.locator(`xpath=${elementXpath}`) await element.screenshot({ ...config, path: savePath }) } else { - await context.body.screenshot({ ...options, path: savePath }) + await context.frame.locator('body').screenshot({ ...options, path: savePath }) } return path } diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts index f68e35423fd6..8514efcf667f 100644 --- a/packages/browser/src/node/commands/type.ts +++ b/packages/browser/src/node/commands/type.ts @@ -1,9 +1,8 @@ -import { parseKeyDef } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' -import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' import type { UserEvent } from '../../../context' import { PlaywrightBrowserProvider } from '../providers/playwright' import { WebdriverBrowserProvider } from '../providers/webdriver' import type { UserEventCommand } from './utils' +import { keyboardImplementation } from './keyboard' export const type: UserEventCommand = async ( context, @@ -14,42 +13,19 @@ export const type: UserEventCommand = async ( const { skipClick = false, skipAutoClose = false } = options if (context.provider instanceof PlaywrightBrowserProvider) { - const { tester, page } = context - const element = tester.locator(`xpath=${xpath}`) - const actions = parseKeyDef(defaultKeyMap, text) + const { frame, page } = context + const element = frame.locator(`xpath=${xpath}`) if (!skipClick) { await element.focus() } - const pressed = new Set() - - for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { - const key = keyDef.key! - - if (pressed.has(key)) { - await page.keyboard.up(key) - pressed.delete(key) - } - - if (!releasePrevious) { - if (key === 'selectall') { - await element.selectText() - continue - } - - for (let i = 1; i <= repeat; i++) { - await page.keyboard.down(key) - } - - if (releaseSelf) { - await page.keyboard.up(key) - } - else { - pressed.add(key) - } - } - } + const { pressed } = await keyboardImplementation( + context.provider, + context.contextId, + text, + () => element.selectText(), + ) if (!skipAutoClose) { for (const key of pressed) { @@ -58,60 +34,25 @@ export const type: UserEventCommand = async ( } } else if (context.provider instanceof WebdriverBrowserProvider) { - const { Key } = await import('webdriverio') const browser = context.browser const markedXpath = `//${xpath}` const element = await browser.$(markedXpath) - const actions = parseKeyDef(defaultKeyMap, text) if (!skipClick && !await element.isFocused()) { await element.click() } - const keys = actions.reduce((acc, { keyDef, repeat, releasePrevious }) => { - const key = keyDef.key! - const code = 'location' in keyDef ? keyDef.key! : keyDef.code! - const special = Key[code as 'Shift'] - if (code === 'Unknown' && key === 'selectall') { - const specialArray = ['selectall'] - Object.assign(specialArray, { special: true }) - acc.push(specialArray) - return acc - } - - if (special) { - const specialArray = [special] - Object.assign(specialArray, { special: true }) - acc.push(specialArray) - } - else { - if (releasePrevious) - return acc - const last = acc[acc.length - 1] - const value = key.repeat(repeat) - if (last && !('special' in last)) { - last.push(value) - } - else { - acc.push([value]) + await keyboardImplementation( + context.provider, + context.contextId, + text, + () => browser.execute(() => { + const element = document.activeElement as HTMLInputElement + if (element) { + element.select() } - } - return acc - }, []) - - for (const key of keys) { - if (key[0] === 'selectall') { - await browser.execute(() => { - const element = document.activeElement as HTMLInputElement - if (element) { - element.select() - } - }) - continue - } - - await browser.keys(key.join('')) - } + }), + ) } else { throw new TypeError(`Provider "${context.provider.name}" does not support typing`) diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 3ba9b44ae400..c1aeeb020f8b 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -102,12 +102,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider { public getCommandsContext(contextId: string) { const page = this.getPage(contextId) - const tester = page.frameLocator('iframe[data-vitest]') return { page, - tester, - get body() { - return page.frameLocator('iframe[data-vitest]').locator('body') + get frame() { + return page.frame('vitest-iframe')! }, } } From 43ae91305007d6cb9c285777dead3401c818a6cb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 16:38:35 +0200 Subject: [PATCH 09/28] chore: update docs --- docs/guide/browser.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 998f9a865c1d..70f6e2fff489 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -370,15 +370,14 @@ Custom functions will override built-in ones if they have the same name. Vitest exposes several `playwright` specific properties on the command context. - `page` references the full page that contains the test iframe. This is the orchestrator HTML and you most likely shouldn't touch it to not break things. -- `tester` is the iframe locator. The API is pretty limited here, but you can chain it further to access your HTML elements. -- `body` is the iframe's `body` locator that exposes more Playwright APIs. +- `frame` is the tester [iframe instance](https://playwright.dev/docs/api/class-frame). It has a simillar API to the page, but it doesn't support certain methods. ```ts import { defineCommand } from '@vitest/browser' export const myCommand = defineCommand(async (ctx, arg1, arg2) => { if (ctx.provider.name === 'playwright') { - const element = await ctx.tester.findByRole('alert') + const element = await ctx.frame.findByRole('alert') const screenshot = await element.screenshot() // do something with the screenshot return difference From 6b75e7ce13177303d3347bdc6dfd1fead60d448e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 17:33:32 +0200 Subject: [PATCH 10/28] chore: remove sendKeys API --- docs/guide/browser.md | 26 +--- packages/browser/context.d.ts | 21 +--- packages/browser/src/node/commands/index.ts | 2 - .../browser/src/node/commands/sendKeys.ts | 111 ------------------ test/browser/test/commands.test.ts | 93 +-------------- 5 files changed, 4 insertions(+), 249 deletions(-) delete mode 100644 packages/browser/src/node/commands/sendKeys.ts diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 70f6e2fff489..62a711c717ba 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -277,31 +277,6 @@ it('handles files', async () => { }) ``` -### Keyboard Interactions - -Vitest also implements Web Test Runner's [`sendKeys` API](https://modern-web.dev/docs/test-runner/commands/#send-keys). It accepts an object with a single property: - -- `type` - types a sequence of characters, this API _is not_ affected by modifier keys, so having `Shift` won't make letters uppercase -- `press` - presses a single key, this API _is_ affected by modifier keys, so having `Shift` will make subsequent characters uppercase -- `up` - holds down a key (supported only with `playwright` provider) -- `down` - releases a key (supported only with `playwright` provider) - -```ts -interface TypePayload { type: string } -interface PressPayload { press: string } -interface DownPayload { down: string } -interface UpPayload { up: string } - -type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload - -declare function sendKeys(payload: SendKeysPayload): Promise -``` - -This is just a simple wrapper around providers APIs. Please refer to their respective documentations for details: - -- [Playwright Keyboard API](https://playwright.dev/docs/api/class-keyboard) -- [Webdriver Keyboard API](https://webdriver.io/docs/api/browser/keys/) - ## Custom Commands You can also add your own commands via [`browser.commands`](/config/#browser-commands) config option. If you develop a library, you can provide them via a `config` hook inside a plugin: @@ -371,6 +346,7 @@ Vitest exposes several `playwright` specific properties on the command context. - `page` references the full page that contains the test iframe. This is the orchestrator HTML and you most likely shouldn't touch it to not break things. - `frame` is the tester [iframe instance](https://playwright.dev/docs/api/class-frame). It has a simillar API to the page, but it doesn't support certain methods. +- `context` refers to the unique [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). ```ts import { defineCommand } from '@vitest/browser' diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 314eef3ab3d7..fdd91e5dad73 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -19,25 +19,6 @@ export interface FsOptions { flag?: string | number } -export interface TypePayload { - type: string -} -export interface PressPayload { - press: string -} -export interface DownPayload { - down: string -} -export interface UpPayload { - up: string -} - -export type SendKeysPayload = - | TypePayload - | PressPayload - | DownPayload - | UpPayload - export interface ScreenshotOptions { element?: Element /** @@ -57,10 +38,10 @@ export interface BrowserCommands { options?: BufferEncoding | (FsOptions & { mode?: number | string }) ) => Promise removeFile: (path: string) => Promise - sendKeys: (payload: SendKeysPayload) => Promise } export interface UserEvent { + setup: () => UserEvent /** * Click on an element. Uses provider's API under the hood and supports all its options. * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 1a7503898c24..f394061c38ea 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -9,14 +9,12 @@ import { removeFile, writeFile, } from './fs' -import { sendKeys } from './sendKeys' import { screenshot } from './screenshot' export default { readFile, removeFile, writeFile, - sendKeys, __vitest_click: click, __vitest_screenshot: screenshot, __vitest_type: type, diff --git a/packages/browser/src/node/commands/sendKeys.ts b/packages/browser/src/node/commands/sendKeys.ts deleted file mode 100644 index cfab77868d6b..000000000000 --- a/packages/browser/src/node/commands/sendKeys.ts +++ /dev/null @@ -1,111 +0,0 @@ -// based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts - -import type { BrowserCommand } from 'vitest/node' -import type { - BrowserCommands, - DownPayload, - PressPayload, - SendKeysPayload, - TypePayload, - UpPayload, -} from '../../../context' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriver' - -function isObject(payload: unknown): payload is Record { - return payload != null && typeof payload === 'object' -} - -function isSendKeysPayload(payload: unknown): boolean { - const validOptions = ['type', 'press', 'down', 'up'] - - if (!isObject(payload)) { - throw new Error('You must provide a `SendKeysPayload` object') - } - - const numberOfValidOptions = Object.keys(payload).filter(key => - validOptions.includes(key), - ).length - const unknownOptions = Object.keys(payload).filter( - key => !validOptions.includes(key), - ) - - if (numberOfValidOptions > 1) { - throw new Error( - `You must provide ONLY one of the following properties to pass to the browser runner: ${validOptions.join( - ', ', - )}.`, - ) - } - if (numberOfValidOptions === 0) { - throw new Error( - `You must provide one of the following properties to pass to the browser runner: ${validOptions.join( - ', ', - )}.`, - ) - } - if (unknownOptions.length > 0) { - throw new Error( - `Unknown options \`${unknownOptions.join(', ')}\` present.`, - ) - } - - return true -} - -function isTypePayload(payload: SendKeysPayload): payload is TypePayload { - return 'type' in payload -} - -function isPressPayload(payload: SendKeysPayload): payload is PressPayload { - return 'press' in payload -} - -function isDownPayload(payload: SendKeysPayload): payload is DownPayload { - return 'down' in payload -} - -function isUpPayload(payload: SendKeysPayload): payload is UpPayload { - return 'up' in payload -} - -export const sendKeys: BrowserCommand< - Parameters -> = async ({ provider, contextId }, payload) => { - if (!isSendKeysPayload(payload) || !payload) { - throw new Error('You must provide a `SendKeysPayload` object') - } - - if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(contextId) - if (isTypePayload(payload)) { - await page.keyboard.type(payload.type) - } - else if (isPressPayload(payload)) { - await page.keyboard.press(payload.press) - } - else if (isDownPayload(payload)) { - await page.keyboard.down(payload.down) - } - else if (isUpPayload(payload)) { - await page.keyboard.up(payload.up) - } - } - else if (provider instanceof WebdriverBrowserProvider) { - const browser = provider.browser! - if (isTypePayload(payload)) { - await browser.keys(payload.type.split('')) - } - else if (isPressPayload(payload)) { - await browser.keys([payload.press]) - } - else { - throw new Error('Only "press" and "type" are supported by webdriverio.') - } - } - else { - throw new TypeError( - `"sendKeys" is not supported for ${provider.name} browser provider.`, - ) - } -} diff --git a/test/browser/test/commands.test.ts b/test/browser/test/commands.test.ts index 7e7c6d4040b2..a838848f7873 100644 --- a/test/browser/test/commands.test.ts +++ b/test/browser/test/commands.test.ts @@ -1,7 +1,7 @@ -import { commands, server } from '@vitest/browser/context' +import { server } from '@vitest/browser/context' import { expect, it } from 'vitest' -const { readFile, writeFile, removeFile, sendKeys, myCustomCommand } = server.commands +const { readFile, writeFile, removeFile, myCustomCommand } = server.commands it('can manipulate files', async () => { const file = './test.txt' @@ -42,95 +42,6 @@ it('can manipulate files', async () => { } }) -// Test Cases from https://modern-web.dev/docs/test-runner/commands/#writing-and-reading-files -it('natively types into an input', async () => { - const keys = 'abc123' - const input = document.createElement('input') - document.body.append(input) - input.focus() - - await commands.sendKeys({ - type: keys, - }) - - expect(input.value).to.equal(keys) - input.remove() -}) - -it('natively presses `Tab`', async () => { - const input1 = document.createElement('input') - const input2 = document.createElement('input') - document.body.append(input1, input2) - input1.focus() - expect(document.activeElement).to.equal(input1) - - await commands.sendKeys({ - press: 'Tab', - }) - - expect(document.activeElement).to.equal(input2) - input1.remove() - input2.remove() -}) - -it.skipIf(server.provider === 'webdriverio')('natively presses `Shift+Tab`', async () => { - const input1 = document.createElement('input') - const input2 = document.createElement('input') - document.body.append(input1, input2) - input2.focus() - expect(document.activeElement).to.equal(input2) - - await sendKeys({ - down: 'Shift', - }) - await sendKeys({ - press: 'Tab', - }) - await sendKeys({ - up: 'Shift', - }) - - expect(document.activeElement).to.equal(input1) - input1.remove() - input2.remove() -}) - -it.skipIf(server.provider === 'webdriverio')('natively holds and then releases a key', async () => { - const input = document.createElement('input') - document.body.append(input) - input.focus() - - await sendKeys({ - down: 'Shift', - }) - // Note that pressed modifier keys are only respected when using `press` or - // `down`, and only when using the `Key...` variants. - await sendKeys({ - press: 'KeyA', - }) - await sendKeys({ - press: 'KeyB', - }) - await sendKeys({ - press: 'KeyC', - }) - await sendKeys({ - up: 'Shift', - }) - await sendKeys({ - press: 'KeyA', - }) - await sendKeys({ - press: 'KeyB', - }) - await sendKeys({ - press: 'KeyC', - }) - - expect(input.value).to.equal('ABCabc') - input.remove() -}) - it('can run custom commands', async () => { const result = await myCustomCommand('arg1', 'arg2') expect(result).toEqual({ From 0a6f9662a67364a712c8b07491dcbca694adafb0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 13 Jun 2024 17:35:28 +0200 Subject: [PATCH 11/28] chore: fix keyboard event on playwright --- docs/guide/browser.md | 53 +++++++++++++++++++ packages/browser/providers/playwright.d.ts | 2 + packages/browser/src/client/context.ts | 4 ++ packages/browser/src/client/tester.html | 1 + .../browser/src/node/commands/keyboard.ts | 17 ++++-- .../browser/src/node/providers/playwright.ts | 1 + test/browser/test/dom.test.ts | 15 ++++++ 7 files changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 62a711c717ba..7ce49d808e16 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -212,6 +212,7 @@ export const server: { * @experimental */ export const userEvent: { + setup: () => UserEvent /** * Click on an element. Uses provider's API under the hood and supports all its options. * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API @@ -219,6 +220,58 @@ export const userEvent: { * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API */ click: (element: Element, options?: UserEventClickOptions) => Promise + /** + * Type text on the keyboard. If any input is focused, it will receive the text, + * otherwise it will be typed on the document. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.keyboard('foo') // translates to: f, o, o + * await userEvent.keyboard('{{a[[') // translates to: {, a, [ + * await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API + */ + keyboard: (text: string) => Promise + /** + * Types text into an element. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.type(input, 'foo') // translates to: f, o, o + * await userEvent.type(input, '{{a[[') // translates to: {, a, [ + * await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + type: (element: Element, text: string, options?: UserEventTypeOptions) => Promise + /** + * Removes all text from an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API + * @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API + */ + clear: (element: Element) => Promise + /** + * Sends a `Tab` key event. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API + */ + tab: (options?: UserEventTabOptions) => 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. + * This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). + * @example + * await userEvent.fill(input, 'foo') // translates to: f, o, o + * await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [ + * await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, } + * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API + * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + fill: (element: Element, text: string) => Promise } /** diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index e13b70884db3..afa6789cbdac 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -1,4 +1,5 @@ import type { + BrowserContext, BrowserContextOptions, Frame, LaunchOptions, @@ -17,5 +18,6 @@ declare module 'vitest/node' { export interface BrowserCommandContext { page: Page frame: Frame + context: BrowserContext } } diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 075bdb05a7ef..429add2f0580 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -59,6 +59,10 @@ function triggerCommand(command: string, ...args: any[]) { } export const userEvent: UserEvent = { + // TODO: actually setup userEvent with config options + setup() { + return userEvent + }, click(element: Element, options: UserEventClickOptions = {}) { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_click', xpath, options) diff --git a/packages/browser/src/client/tester.html b/packages/browser/src/client/tester.html index 3136578ad3bf..8dfad61fb9b0 100644 --- a/packages/browser/src/client/tester.html +++ b/packages/browser/src/client/tester.html @@ -20,6 +20,7 @@ {__VITEST_SCRIPTS__} Promise + /** + * Hovers over an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + hover: (element: Element, options?: UserEventHoverOptions) => Promise + /** + * Moves cursor position to the body element. Uses provider's API under the hood. + * By default, the cursor position is in the center (in webdriverio) or in some visible place (in playwright) + * of the body element, so if the current element is already there, this will have no effect. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + unhover: (element: Element, options?: UserEventHoverOptions) => 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. @@ -100,22 +116,20 @@ export interface UserEvent { * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API */ - fill: (element: Element, text: string) => Promise + fill: (element: Element, text: string, options?: UserEventFillOptions) => Promise } -export interface UserEventClickOptions { - [key: string]: any -} +export interface UserEventFillOptions {} +export interface UserEventHoverOptions {} +export interface UserEventClickOptions {} export interface UserEventTabOptions { shift?: boolean - [key: string]: any } export interface UserEventTypeOptions { skipClick?: boolean skipAutoClose?: boolean - [key: string]: any } type Platform = diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index afa6789cbdac..ec8bc5439f90 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -21,3 +21,15 @@ declare module 'vitest/node' { context: BrowserContext } } + +type PWHoverOptions = Parameters[1] +type PWClickOptions = Parameters[1] +type PWFillOptions = Parameters[2] +type PWScreenshotOptions = Parameters[0] + +declare module '@vitest/browser/context' { + export interface UserEventHoverOptions extends PWHoverOptions {} + export interface UserEventClickOptions extends PWClickOptions {} + export interface UserEventFillOptions extends PWFillOptions {} + export interface ScreenshotOptions extends PWScreenshotOptions {} +} diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 429add2f0580..c81a797ca501 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -75,9 +75,9 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_clear', xpath) }, - fill(element: Element, text: string) { + fill(element: Element, text: string, options) { const xpath = convertElementToXPath(element) - return triggerCommand('__vitest_fill', xpath, text) + return triggerCommand('__vitest_fill', xpath, text, options) }, tab(options: UserEventTabOptions = {}) { return triggerCommand('__vitest_tab', options) @@ -85,6 +85,14 @@ export const userEvent: UserEvent = { keyboard(text: string) { return triggerCommand('__vitest_keyboard', text) }, + hover(element: Element) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_hover', xpath) + }, + unhover(element: Element) { + const xpath = convertElementToXPath(element.ownerDocument.body) + return triggerCommand('__vitest_hover', xpath) + }, } const screenshotIds: Record> = {} diff --git a/packages/browser/src/node/commands/fill.ts b/packages/browser/src/node/commands/fill.ts index 25ad56bbeead..935720e33751 100644 --- a/packages/browser/src/node/commands/fill.ts +++ b/packages/browser/src/node/commands/fill.ts @@ -7,11 +7,12 @@ export const fill: UserEventCommand = async ( context, xpath, text, + options = {}, ) => { if (context.provider instanceof PlaywrightBrowserProvider) { const { frame } = context const element = frame.locator(`xpath=${xpath}`) - await element.fill(text) + await element.fill(text, options) } else if (context.provider instanceof WebdriverBrowserProvider) { const browser = context.browser diff --git a/packages/browser/src/node/commands/hover.ts b/packages/browser/src/node/commands/hover.ts new file mode 100644 index 000000000000..44bc99812b4a --- /dev/null +++ b/packages/browser/src/node/commands/hover.ts @@ -0,0 +1,23 @@ +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const hover: UserEventCommand = async ( + context, + xpath, + options = {}, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + await context.frame.locator(`xpath=${xpath}`).hover(options) + } + else if (context.provider instanceof WebdriverBrowserProvider) { + const browser = context.browser + const markedXpath = `//${xpath}` + const element = await browser.$(markedXpath) + await element.moveTo(options) + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support hover`) + } +} From a5ba26fce88a6ee600e1fb5129643bf29ae6ca40 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 14 Jun 2024 12:01:39 +0200 Subject: [PATCH 14/28] chore: add more events! --- packages/browser/context.d.ts | 7 ++++ packages/browser/providers/playwright.d.ts | 3 ++ packages/browser/src/client/context.ts | 21 ++++++++--- packages/browser/src/node/commands/click.ts | 31 +++++++++++++--- packages/browser/src/node/commands/index.ts | 5 ++- .../browser/src/node/commands/keyboard.ts | 3 ++ packages/browser/src/node/commands/select.ts | 36 +++++++++++++++++++ 7 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 packages/browser/src/node/commands/select.ts diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 20a1cb12aab1..d7908146365c 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -49,6 +49,12 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API */ click: (element: Element, options?: UserEventClickOptions) => Promise + dblClick: (element: Element, options?: UserEventClickOptions) => Promise + selectOptions: ( + element: Element, + values: HTMLElement | HTMLElement[] | string | string[], + options?: UserEventSelectOptions, + ) => Promise /** * Type text on the keyboard. If any input is focused, it will receive the text, * otherwise it will be typed on the document. Uses provider's API under the hood. @@ -121,6 +127,7 @@ export interface UserEvent { export interface UserEventFillOptions {} export interface UserEventHoverOptions {} +export interface UserEventSelectOptions {} export interface UserEventClickOptions {} export interface UserEventTabOptions { diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index ec8bc5439f90..865f609d4f38 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -26,10 +26,13 @@ type PWHoverOptions = Parameters[1] type PWClickOptions = Parameters[1] type PWFillOptions = Parameters[2] type PWScreenshotOptions = Parameters[0] +type PWSelectOptions = Parameters[2] declare module '@vitest/browser/context' { export interface UserEventHoverOptions extends PWHoverOptions {} export interface UserEventClickOptions extends PWClickOptions {} export interface UserEventFillOptions extends PWFillOptions {} + export interface UserEventSelectOptions extends PWSelectOptions {} + export interface ScreenshotOptions extends PWScreenshotOptions {} } diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index c81a797ca501..327f696e0aa6 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -67,6 +67,16 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_click', xpath, options) }, + dblClick(element: Element, options: UserEventClickOptions = {}) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_dblClick', xpath, options) + }, + selectOptions(element, value) { + const values = (Array.isArray(value) ? value : [value]).map( + v => typeof v === 'string' ? v : { element: convertElementToXPath(v) }, + ) + return triggerCommand('__vitest_selectOptions', convertElementToXPath(element), values) + }, type(element: Element, text: string, options: UserEventTypeOptions = {}) { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_type', xpath, text, options) @@ -75,10 +85,6 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element) return triggerCommand('__vitest_clear', xpath) }, - fill(element: Element, text: string, options) { - const xpath = convertElementToXPath(element) - return triggerCommand('__vitest_fill', xpath, text, options) - }, tab(options: UserEventTabOptions = {}) { return triggerCommand('__vitest_tab', options) }, @@ -93,6 +99,13 @@ export const userEvent: UserEvent = { const xpath = convertElementToXPath(element.ownerDocument.body) return triggerCommand('__vitest_hover', xpath) }, + + // non userEvent events, but still useful + fill(element: Element, text: string, options) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_fill', xpath, text, options) + }, + // TODO: add dragTo } const screenshotIds: Record> = {} diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts index c2a3266f1936..c36e976c2b7d 100644 --- a/packages/browser/src/node/commands/click.ts +++ b/packages/browser/src/node/commands/click.ts @@ -12,14 +12,35 @@ export const click: UserEventCommand = async ( if (provider instanceof PlaywrightBrowserProvider) { const tester = context.frame await tester.locator(`xpath=${xpath}`).click(options) - return } - if (provider instanceof WebdriverBrowserProvider) { + else if (provider instanceof WebdriverBrowserProvider) { const page = provider.browser! const markedXpath = `//${xpath}` const element = await page.$(markedXpath) - await element.click(options) - return + await element.click(options as any) + } + else { + throw new TypeError(`Provider "${provider.name}" doesn't support click command`) + } +} + +export const dblClick: UserEventCommand = async ( + context, + xpath, + options = {}, +) => { + const provider = context.provider + if (provider instanceof PlaywrightBrowserProvider) { + const tester = context.frame + await tester.locator(`xpath=${xpath}`).dblclick(options) + } + else if (provider instanceof WebdriverBrowserProvider) { + const page = provider.browser! + const markedXpath = `//${xpath}` + const element = await page.$(markedXpath) + await element.doubleClick() + } + else { + throw new TypeError(`Provider "${provider.name}" doesn't support dblClick command`) } - throw new Error(`Provider "${provider.name}" doesn't support click command`) } diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index f394061c38ea..6067485ce5b7 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -1,7 +1,8 @@ -import { click } from './click' +import { click, dblClick } from './click' import { type } from './type' import { clear } from './clear' import { fill } from './fill' +import { selectOptions } from './select' import { tab } from './tab' import { keyboard } from './keyboard' import { @@ -16,10 +17,12 @@ export default { removeFile, writeFile, __vitest_click: click, + __vitest_dblClick: dblClick, __vitest_screenshot: screenshot, __vitest_type: type, __vitest_clear: clear, __vitest_fill: fill, __vitest_tab: tab, __vitest_keyboard: keyboard, + __vitest_selectOptions: selectOptions, } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index 4ef19cc0f050..272bd8fcc499 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -66,6 +66,9 @@ export async function keyboardImplementation( for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { const key = keyDef.key! + // TODO: instead of calling down/up for each key, join non special + // together, and call `type` once for all non special keys, + // and then `press` for special keys if (pressed.has(key)) { await page.keyboard.up(key) pressed.delete(key) diff --git a/packages/browser/src/node/commands/select.ts b/packages/browser/src/node/commands/select.ts new file mode 100644 index 000000000000..4d9ddb67919c --- /dev/null +++ b/packages/browser/src/node/commands/select.ts @@ -0,0 +1,36 @@ +import type { ElementHandle } from 'playwright' +import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const selectOptions: UserEventCommand = async ( + context, + xpath, + values, +) => { + const value = values as any as (string | { element: string })[] + + if (context.provider instanceof PlaywrightBrowserProvider) { + const { frame } = context + const selectElement = frame.locator(`xpath=${xpath}`) + + const values = await Promise.all(value.map(async (v) => { + if (typeof v === 'string') + return v + const elementHandler = await frame.locator(`xpath=${v.element}`).elementHandle() + if (!elementHandler) { + throw new Error(`Element not found: ${v.element}`) + } + return elementHandler + })) as (readonly string[]) | (readonly ElementHandle[]) + + await selectElement.selectOption(values) + } + else if (context.provider instanceof WebdriverBrowserProvider) { + // TODO + } + else { + throw new TypeError(`Provider "${context.provider.name}" doesn't support selectOptions command`) + } +} From 06e1f58d70f9fa395812f55eb2dec27bf351f508 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Jun 2024 14:43:47 +0200 Subject: [PATCH 15/28] docs: add documentation for userEvent commands --- docs/guide/browser.md | 259 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 258 insertions(+), 1 deletion(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index a917a3f4b4aa..9c27502c8e09 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -276,7 +276,7 @@ export const userEvent: { */ unhover: (element: Element, options?: UserEventHoverOptions) => Promise /** - * Fills an input element with text. This will remove any existing text in the input before typing the new text. + * Fills an input element with text. This will remove any existing text in the input before typing the new value. * Uses provider's API under the hood. * This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). * @example @@ -313,6 +313,263 @@ export const page: { } ``` +## Interactivity API + +Vitest implements a subset of [`@testing-library/user-event`](https://testing-library.com/docs/user-event) APIs using [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) or [webdriver](https://www.w3.org/TR/webdriver/) APIs instead of faking events which makes the browser behaviour more reliable and consistent. + +Almost every `userEvent` method inherits its provider options. To see all available options in your IDE, add `webdriver` or `playwright` types to your `tsconfig.json` file: + +::: code-group +```json [playwright] +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/playwright" + ] + } +} +``` +```json [webdriverio] +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/webdriverio" + ] + } +} +``` +::: + +### userEvent.click + +- **Type:** `(element: Element, options?: UserEventClickOptions) => Promise` + +Clicks on an element. Inherits provider's options. Please refer to your provider's documentation for detailed explanaition about how this method works. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('clicks on an element', () => { + const logo = screen.getByRole('img', { name: /logo/ }) + + await userEvent.click(logo) +}) +``` + +References: + +- [Playwright `locator.click` API](https://playwright.dev/docs/api/class-locator#locator-click) +- [WebdriverIO `element.click` API](https://webdriver.io/docs/api/element/click/) +- [testing-library `click` API](https://testing-library.com/docs/user-event/convenience/#click) + +### userEvent.fill + +- **Type:** `(element: Element, text: string) => Promise` + +Fills an input/textarea/conteneditable element with text. This will remove any existing text in the input before typing the new value. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('update input', () => { + const input = screen.getByRole('input') + + await userEvent.fill(input, 'foo') // input.value == foo + await userEvent.fill(input, '{{a[[') // input.value == {{a[[ + await userEvent.fill(input, '{Shift}') // input.value == {Shift} +}) +``` + +::: tip +This API is faster than using [`userEvent.type`](#userevent-type) or [`userEvent.keyboard`](#userevent-keyboard), but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}{selectall}`). + +We recommend using this API over [`userEvent.type`](#userevent-type) in situations when you don't need to enter special characters. +::: + +References: + +- [Playwright `locator.fill` API](https://playwright.dev/docs/api/class-locator#locator-fill) +- [WebdriverIO `element.setValue` API](https://webdriver.io/docs/api/element/setValue) +- [testing-library `type` API](https://testing-library.com/docs/user-event/utility/#type) + +### userEvent.keyboard + +- **Type:** `(text: string) => Promise` + +The `userEvent.keyboard` allows you to trigger keyboard strokes. If any input has a focus, it will type characters into that input. Otherwise, it will trigger keyboard events on the currently focused element (`document.body` if there are no focused elements). + +This API supports [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard). + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('trigger keystrokes', () => { + await userEvent.keyboard('foo') // translates to: f, o, o + await userEvent.keyboard('{{a[[') // translates to: {, a, [ + await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + await userEvent.keyboard('{a>5}') // press a without releasing it and trigger 5 keydown + await userEvent.keyboard('{a>5/}') // press a for 5 keydown and then release it +}) +``` + +References: + +- [Playwright `locator.press` API](https://playwright.dev/docs/api/class-locator#locator-press) +- [WebdriverIO `browser.keys` API](https://webdriver.io/docs/api/browser/keys) +- [testing-library `type` API](https://testing-library.com/docs/user-event/utility/#type) + +### userEvent.tab + +- **Type:** `(options?: UserEventTabOptions) => Promise` + +Sends a `Tab` key event. This is a shorthand for `userEvent.keyboard('{tab}')`. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' +import '@testing-library/jest-dom' // adds support for "toHaveValue" + +test('tab works', () => { + const [input1, input2] = screen.getAllByRole('input') + + expect(input1).toHaveFocus() + + await userEvent.tab() + + expect(input2).toHaveFocus() + + await userEvent.tab({ shift: true }) + + expect(input1).toHaveFocus() +}) +``` + +References: + +- [Playwright `locator.press` API](https://playwright.dev/docs/api/class-locator#locator-press) +- [WebdriverIO `browser.keys` API](https://webdriver.io/docs/api/browser/keys) +- [testing-library `tab` API](https://testing-library.com/docs/user-event/convenience/#tab) + +### userEvent.type + +- **Type:** `(element: Element, text: string, options?: UserEventTypeOptions) => Promise` + +::: warning +If you don't rely on [special characters](https://testing-library.com/docs/user-event/keyboard) (e.g., `{shift}` or `{selectall}`), it is recommended to use [`userEvent.fill`](#userevent-fill) instead. +::: + +The `type` method implements `@testing-library/user-event`'s [`type`](https://testing-library.com/docs/user-event/utility/#type) utility built on top of [`keyboard`](https://testing-library.com/docs/user-event/keyboard) API. + +This function allows you to type characters into an input/textarea/conteneditable element. It supports [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard). + +If you just need to press characters without an input, use [`userEvent.keyboard`](#userevent-keyboard) API. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('update input', () => { + const input = screen.getByRole('input') + + await userEvent.type(input, 'foo') // input.value == foo + await userEvent.type(input, '{{a[[') // input.value == foo{a[ + await userEvent.type(input, '{Shift}') // input.value == foo{a[ +}) +``` + +References: + +- [Playwright `locator.press` API](https://playwright.dev/docs/api/class-locator#locator-press) +- [WebdriverIO `browser.keys` API](https://webdriver.io/docs/api/browser/keys) +- [testing-library `type` API](https://testing-library.com/docs/user-event/utility/#type) + +### userEvent.clear + +- **Type:** `(element: Element) => Promise` + +This method clear the input element content. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' +import '@testing-library/jest-dom' // adds support for "toHaveValue" + +test('clears input', () => { + const input = screen.getByRole('input') + + await userEvent.fill(input, 'foo') + expect(input).toHaveValue('foo') + + await userEvent.clear(input) + expect(input).toHaveValue('') +}) +``` + +References: + +- [Playwright `locator.clear` API](https://playwright.dev/docs/api/class-locator#locator-clear) +- [WebdriverIO `element.clearValue` API](https://webdriver.io/docs/api/element/clearValue) +- [testing-library `clear` API](https://testing-library.com/docs/user-event/utility/#clear) + +### userEvent.hover + +- **Type:** `(element: Element, options?: UserEventHoverOptions) => Promise` + +This method moves the cursor position to selected element. Please refer to your provider's documentation for detailed explanaition about how this method works. + +::: warning +If you are using `webdriverio` provider, the cursor will move to the center of the element by default. + +If you are using `playwright` provider, the cursor moves to "some" visible point of the element. +::: + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('unhover logo element', () => { + const logo = screen.getByRole('img', { name: /logo/ }) + + await userEvent.unhover(logo) +}) +``` + +References: + +- [Playwright `locator.hover` API](https://playwright.dev/docs/api/class-locator#locator-hover) +- [WebdriverIO `element.moveTo` API](https://webdriver.io/docs/api/element/moveTo/) +- [testing-library `hover` API](https://testing-library.com/docs/user-event/convenience/#hover) + +### userEvent.unhover + +- **Type:** `(element: Element, options?: UserEventHoverOptions) => Promise` + +This works the same as [`userEvent.hover`](#userevent-hover), but moves the cursor to the `document.body` element instead. + +::: warning +By default, the cursor position is in the center (in `webdriverio` provider) or in "some" visible place (in `playwright` provider) of the body element, so if the currently hovered element is already in the same position, this method will have no effect. +::: + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('unhover logo element', () => { + const logo = screen.getByRole('img', { name: /logo/ }) + + await userEvent.unhover(logo) +}) +``` + +References: + +- [Playwright `locator.hover` API](https://playwright.dev/docs/api/class-locator#locator-hover) +- [WebdriverIO `element.moveTo` API](https://webdriver.io/docs/api/element/moveTo/) +- [testing-library `hover` API](https://testing-library.com/docs/user-event/convenience/#hover) + ## Commands Command is a function that invokes another function on the server and passes down the result back to the browser. Vitest exposes several built-in commands you can use in your browser tests. From 37b8e5f201c266bf640c7744d59cee65ac3907bf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Jun 2024 14:47:16 +0200 Subject: [PATCH 16/28] chore: cleanup --- docs/guide/browser.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 9c27502c8e09..05a0bfe53b49 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -430,7 +430,7 @@ Sends a `Tab` key event. This is a shorthand for `userEvent.keyboard('{tab}')`. ```ts import { userEvent } from '@vitest/browser/context' import { screen } from '@testing-library/dom' -import '@testing-library/jest-dom' // adds support for "toHaveValue" +import '@testing-library/jest-dom' // adds support for "toHaveFocus" test('tab works', () => { const [input1, input2] = screen.getAllByRole('input') @@ -530,10 +530,10 @@ If you are using `playwright` provider, the cursor moves to "some" visible point import { userEvent } from '@vitest/browser/context' import { screen } from '@testing-library/dom' -test('unhover logo element', () => { +test('hovers logo element', () => { const logo = screen.getByRole('img', { name: /logo/ }) - await userEvent.unhover(logo) + await userEvent.hover(logo) }) ``` From f1e07164501a30f529883ac5272559f15188f81c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 17 Jun 2024 16:42:02 +0200 Subject: [PATCH 17/28] feat: add dblClick and selectOptions --- docs/guide/browser.md | 88 +++++++++++++ packages/browser/context.d.ts | 28 ++++- packages/browser/providers/playwright.d.ts | 2 + packages/browser/src/client/context.ts | 55 +++++++- .../src/client/public/esm-client-injector.js | 1 + packages/browser/src/client/utils.ts | 1 + packages/browser/src/node/commands/click.ts | 10 +- packages/browser/src/node/commands/fill.ts | 3 +- packages/browser/src/node/commands/hover.ts | 3 +- .../browser/src/node/commands/keyboard.ts | 3 +- packages/browser/src/node/commands/select.ts | 25 +++- packages/browser/src/node/commands/type.ts | 2 +- packages/browser/src/node/index.ts | 2 + test/browser/test/dom.test.ts | 117 +++++++++++++++++- 14 files changed, 314 insertions(+), 26 deletions(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 05a0bfe53b49..325d148be36e 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -220,6 +220,25 @@ export const userEvent: { * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API */ click: (element: Element, options?: UserEventClickOptions) => Promise + /** + * Triggers a double click event on an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-dblclick} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#dblClick} testing-library API + */ + dblClick: (element: Element, options?: UserEventDoubleClickOptions) => Promise + /** + * Choose one or more values from a select element. Uses provider's API under the hood. + * If select doesn't have `multiple` attribute, only the first value will be selected. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-select-option} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions} testing-library API + */ + selectOptions: ( + element: Element, + values: HTMLElement | HTMLElement[] | string | string[], + options?: UserEventSelectOptions, + ) => Promise /** * Type text on the keyboard. If any input is focused, it will receive the text, * otherwise it will be typed on the document. Uses provider's API under the hood. @@ -363,6 +382,31 @@ References: - [WebdriverIO `element.click` API](https://webdriver.io/docs/api/element/click/) - [testing-library `click` API](https://testing-library.com/docs/user-event/convenience/#click) +### userEvent.dblClick + +- **Type:** `(element: Element, options?: UserEventDoubleClickOptions) => Promise` + +Triggers a double click event on an element + +Please refer to your provider's documentation for detailed explanaition about how this method works. + +```ts +import { userEvent } from '@vitest/browser/context' +import { screen } from '@testing-library/dom' + +test('triggers a double click on an element', () => { + const logo = screen.getByRole('img', { name: /logo/ }) + + await userEvent.dblClick(logo) +}) +``` + +References: + +- [Playwright `locator.dblclick` API](https://playwright.dev/docs/api/class-locator#locator-dblclick) +- [WebdriverIO `element.doubleClick` API](https://webdriver.io/docs/api/element/doubleClick/) +- [testing-library `dblClick` API](https://testing-library.com/docs/user-event/convenience/#dblClick) + ### userEvent.fill - **Type:** `(element: Element, text: string) => Promise` @@ -514,6 +558,50 @@ References: - [WebdriverIO `element.clearValue` API](https://webdriver.io/docs/api/element/clearValue) - [testing-library `clear` API](https://testing-library.com/docs/user-event/utility/#clear) +### userEvent.selectOptions + +- **Type:** `(element: Element, values: HTMLElement | HTMLElement[] | string | string[], options?: UserEventSelectOptions) => Promise` + +The `userEvent.selectOptions` allows selecting a value in a `