Skip to content

Commit

Permalink
feat(browser): support click event (#5777)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Jun 3, 2024
1 parent 041b9c4 commit 839c39f
Show file tree
Hide file tree
Showing 13 changed files with 180 additions and 158 deletions.
15 changes: 15 additions & 0 deletions docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ export const server: {
commands: BrowserCommands
}

/**
* Handler for user interactions. The support is provided by the browser provider (`playwright` or `webdriverio`).
* If used with `none` provider, fallbacks to simulated events via `@testing-library/user-event`.
* @experimental
*/
export const 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
* @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API
*/
click: (element: Element, options?: UserEventClickOptions) => Promise<void>
}

/**
* Available commands for the browser.
* A shortcut to `server.commands`.
Expand Down
21 changes: 21 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export interface BrowserCommands {
sendKeys: (payload: SendKeysPayload) => Promise<void>
}

export interface 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
* @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API
* @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API
*/
click: (element: Element, options?: UserEventClickOptions) => Promise<void>
}

export interface UserEventClickOptions {
[key: string]: any
}

type Platform =
| 'aix'
| 'android'
Expand Down Expand Up @@ -72,6 +86,13 @@ export const server: {
commands: BrowserCommands
}

/**
* Handler for user interactions. The support is provided by the browser provider (`playwright` or `webdriverio`).
* If used with `none` provider, fallbacks to simulated events via `@testing-library/user-event`.
* @experimental
*/
export const userEvent: UserEvent

/**
* Available commands for the browser.
* A shortcut to `server.commands`.
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
}
},
"dependencies": {
"@testing-library/dom": "^9.3.3",
"@testing-library/user-event": "^14.5.2",
"@vitest/utils": "workspace:*",
"magic-string": "^0.30.10",
"sirv": "^2.0.4"
Expand Down
24 changes: 24 additions & 0 deletions packages/browser/src/node/commands/click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Page } from 'playwright'
import type { UserEvent } from '../../../context'
import type { UserEventCommand } from './utils'

export const click: UserEventCommand<UserEvent['click']> = async (
{ provider },
element,
options = {},
) => {
if (provider.name === 'playwright') {
const page = (provider as any).page as Page
await page.frameLocator('iframe[data-vitest]').locator(`xpath=${element}`).click(options)
return
}
if (provider.name === 'webdriverio') {
const page = (provider as any).browser as WebdriverIO.Browser
const frame = await page.findElement('css selector', 'iframe[data-vitest]')
await page.switchToFrame(frame)
const xpath = `//${element}`
await (await page.$(xpath)).click(options)
return
}
throw new Error(`Provider "${provider.name}" doesn't support click command`)
}
2 changes: 2 additions & 0 deletions packages/browser/src/node/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { click } from './click'
import {
readFile,
removeFile,
Expand All @@ -10,4 +11,5 @@ export default {
removeFile,
writeFile,
sendKeys,
__vitest_click: click,
}
10 changes: 10 additions & 0 deletions packages/browser/src/node/commands/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { BrowserCommand } from 'vitest/node'

export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<
ConvertUserEventParameters<Parameters<T>>
>

type ConvertElementToLocator<T> = T extends Element ? string : T
type ConvertUserEventParameters<T extends unknown[]> = {
[K in keyof T]: ConvertElementToLocator<T[K]>
}
23 changes: 5 additions & 18 deletions packages/browser/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,25 +153,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
name: 'vitest:browser:tests',
enforce: 'pre',
async config() {
const {
include,
exclude,
includeSource,
dir,
root,
} = project.config
const projectRoot = dir || root
const entries = await project.globAllTestFiles(include, exclude, includeSource, projectRoot)
return {
optimizeDeps: {
entries: [
...entries,
'vitest',
'vitest/utils',
'vitest/browser',
'vitest/runners',
'@vitest/utils',
],
exclude: [
'vitest',
'vitest/utils',
Expand All @@ -181,6 +164,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
'std-env',
'tinybench',
'tinyspy',
'pathe',

// loupe is manually transformed
'loupe',
Expand All @@ -189,11 +173,14 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
'vitest > @vitest/utils > pretty-format',
'vitest > @vitest/snapshot > pretty-format',
'vitest > @vitest/snapshot > magic-string',
'vitest > diff-sequences',
'vitest > pretty-format',
'vitest > pretty-format > ansi-styles',
'vitest > pretty-format > ansi-regex',
'vitest > chai',
'vitest > @vitest/runner > p-limit',
'vitest > @vitest/utils > diff-sequences',
'@vitest/browser > @testing-library/user-event',
'@vitest/browser > @testing-library/dom',
],
},
}
Expand Down
69 changes: 64 additions & 5 deletions packages/browser/src/node/plugins/pluginContext.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { fileURLToPath } from 'node:url'
import type { Plugin } from 'vitest/config'
import type { WorkspaceProject } from 'vitest/node'
import type { BrowserProvider, WorkspaceProject } from 'vitest/node'
import { dirname } from 'pathe'
import type { PluginContext } from 'rollup'
import { slash } from '@vitest/utils'
import builtinCommands from '../commands/index'

const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context'
const ID_CONTEXT = '@vitest/browser/context'

const __dirname = dirname(fileURLToPath(import.meta.url))

export default function BrowserContext(project: WorkspaceProject): Plugin {
project.config.browser.commands ??= {}
for (const [name, command] of Object.entries(builtinCommands))
Expand All @@ -25,27 +31,32 @@ export default function BrowserContext(project: WorkspaceProject): Plugin {
},
load(id) {
if (id === VIRTUAL_ID_CONTEXT)
return generateContextFile(project)
return generateContextFile.call(this, project)
},
}
}

function generateContextFile(project: WorkspaceProject) {
async function generateContextFile(this: PluginContext, project: WorkspaceProject) {
const commands = Object.keys(project.config.browser.commands ?? {})
const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
const provider = project.browserProvider!

const commandsCode = commands.map((command) => {
return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", ${filepathCode}, args),`
return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", filepath(), args),`
}).join('\n')

const userEventNonProviderImport = await getUserEventImport(provider, this.resolve.bind(this))

return `
${userEventNonProviderImport}
const filepath = () => ${filepathCode}
const rpc = () => __vitest_worker__.rpc
const channel = new BroadcastChannel('vitest')
export const server = {
platform: ${JSON.stringify(process.platform)},
version: ${JSON.stringify(process.version)},
provider: ${JSON.stringify(project.browserProvider!.name)},
provider: ${JSON.stringify(provider.name)},
browser: ${JSON.stringify(project.config.browser.name)},
commands: {
${commandsCode}
Expand All @@ -71,7 +82,55 @@ export const page = {
}
})
})
},
}
export const userEvent = ${getUserEventScript(project)}
function convertElementToXPath(element) {
if (!element || !(element instanceof Element)) {
// TODO: better error message
throw new Error('Expected element to be an instance of Element')
}
return getPathTo(element)
}
function getPathTo(element) {
if (element.id !== '')
return \`id("\${element.id}")\`
if (!element.parentNode || element === document.documentElement)
return element.tagName
let ix = 0
const siblings = element.parentNode.childNodes
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i]
if (sibling === element)
return \`\${getPathTo(element.parentNode)}/\${element.tagName}[\${ix + 1}]\`
if (sibling.nodeType === 1 && sibling.tagName === element.tagName)
ix++
}
}
`
}

async function getUserEventImport(provider: BrowserProvider, resolve: (id: string, importer: string) => Promise<null | { id: string }>) {
if (provider.name !== 'none')
return ''
const resolved = await resolve('@testing-library/user-event', __dirname)
if (!resolved)
throw new Error(`Failed to resolve user-event package from ${__dirname}`)
return `import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}'`
}

function getUserEventScript(project: WorkspaceProject) {
if (project.browserProvider?.name === 'none')
return `__vitest_user_event__`
return `{
async click(element, options) {
const xpath = convertElementToXPath(element)
return rpc().triggerCommand('__vitest_click', filepath(), options ? [xpath, options] : [xpath]);
},
}`
}
1 change: 0 additions & 1 deletion packages/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@
"local-pkg": "^0.5.0",
"log-update": "^5.0.1",
"micromatch": "^4.0.5",
"p-limit": "^5.0.0",
"pretty-format": "^29.7.0",
"prompts": "^2.4.2",
"strip-ansi": "^7.1.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/utils/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export async function getModuleGraph(ctx: Vitest, projectName: string, id: strin
async function get(mod?: ModuleNode, seen = new Map<ModuleNode, string>()) {
if (!mod || !mod.id)
return
if (mod.id === '\0@vitest/browser/context')
return
if (seen.has(mod))
return seen.get(mod)
let id = clearId(mod.id)
Expand Down
Loading

0 comments on commit 839c39f

Please sign in to comment.