Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(browser): support click event #5777

Merged
merged 10 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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