feat: introduce separate packages for browser mode providers (#8629)

This commit is contained in:
Vladimir 2025-10-01 15:58:13 +02:00 committed by GitHub
parent 88e62c7586
commit 0dc93ea988
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
217 changed files with 2759 additions and 1717 deletions

View File

@ -253,6 +253,11 @@ export default ({ mode }: { mode: string }) => {
link: '/guide/browser/webdriverio',
docFooterText: 'Configuring WebdriverIO | Browser Mode',
},
{
text: 'Configuring Preview',
link: '/guide/browser/preview',
docFooterText: 'Configuring Preview | Browser Mode',
},
],
},
{

View File

@ -7,10 +7,10 @@ title: Assertion API | Browser Mode
Vitest provides a wide range of DOM assertions out of the box forked from [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) library with the added support for locators and built-in retry-ability.
::: tip TypeScript Support
If you are using [TypeScript](/guide/browser/#typescript) or want to have correct type hints in `expect`, make sure you have `@vitest/browser/context` referenced somewhere. If you never imported from there, you can add a `reference` comment in any file that's covered by your `tsconfig.json`:
If you are using [TypeScript](/guide/browser/#typescript) or want to have correct type hints in `expect`, make sure you have `vitest/browser` referenced somewhere. If you never imported from there, you can add a `reference` comment in any file that's covered by your `tsconfig.json`:
```ts
/// <reference types="@vitest/browser/context" />
/// <reference types="vitest/browser" />
```
:::
@ -18,7 +18,7 @@ Tests in the browser might fail inconsistently due to their asynchronous nature.
```ts
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
test('error banner is rendered', async () => {
triggerError()

View File

@ -20,7 +20,7 @@ This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#ser
:::
```ts
import { server } from '@vitest/browser/context'
import { server } from 'vitest/browser'
const { readFile, writeFile, removeFile } = server.commands
@ -38,10 +38,10 @@ it('handles files', async () => {
## CDP Session
Vitest exposes access to raw Chrome Devtools Protocol via the `cdp` method exported from `@vitest/browser/context`. It is mostly useful to library authors to build tools on top of it.
Vitest exposes access to raw Chrome Devtools Protocol via the `cdp` method exported from `vitest/browser`. It is mostly useful to library authors to build tools on top of it.
```ts
import { cdp } from '@vitest/browser/context'
import { cdp } from 'vitest/browser'
const input = document.createElement('input')
document.body.appendChild(input)
@ -97,10 +97,10 @@ export default function BrowserCommands(): Plugin {
}
```
Then you can call it inside your test by importing it from `@vitest/browser/context`:
Then you can call it inside your test by importing it from `vitest/browser`:
```ts
import { commands } from '@vitest/browser/context'
import { commands } from 'vitest/browser'
import { expect, test } from 'vitest'
test('custom command works correctly', async () => {
@ -109,7 +109,7 @@ test('custom command works correctly', async () => {
})
// if you are using TypeScript, you can augment the module
declare module '@vitest/browser/context' {
declare module 'vitest/browser' {
interface BrowserCommands {
myCustomCommand: (arg1: string, arg2: string) => Promise<{
someValue: true

View File

@ -138,7 +138,7 @@ The key is using `page.elementLocator()` to bridge Testing Library's DOM output
```jsx
// For Solid.js components
import { render } from '@testing-library/solid'
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
test('Solid component handles user interaction', async () => {
// Use Testing Library to render the component
@ -564,7 +564,7 @@ import { render } from 'vitest-browser-react' // [!code ++]
### Key Differences
- Use `await expect.element()` instead of `expect()` for DOM assertions
- Use `@vitest/browser/context` for user interactions instead of `@testing-library/user-event`
- Use `vitest/browser` for user interactions instead of `@testing-library/user-event`
- Browser Mode provides real browser environment for accurate testing
## Learn More

View File

@ -4,7 +4,7 @@ You can change the browser configuration by updating the `test.browser` field in
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -47,14 +47,11 @@ Run all tests inside a browser by default. Note that `--browser` only works if y
## browser.instances
- **Type:** `BrowserConfig`
- **Default:** `[{ browser: name }]`
- **Default:** `[]`
Defines multiple browser setups. Every config has to have at least a `browser` field. The config supports your providers configurations:
Defines multiple browser setups. Every config has to have at least a `browser` field.
- [Configuring Playwright](/guide/browser/playwright)
- [Configuring WebdriverIO](/guide/browser/webdriverio)
In addition to that, you can also specify most of the [project options](/config/) (not marked with a <NonProjectOption /> icon) and some of the `browser` options like `browser.testerHtmlPath`.
You can specify most of the [project options](/config/) (not marked with a <NonProjectOption /> icon) and some of the `browser` options like `browser.testerHtmlPath`.
::: warning
Every browser config inherits options from the root config:
@ -79,8 +76,6 @@ export default defineConfig({
})
```
During development, Vitest supports only one [non-headless](#browser-headless) configuration. You can limit the headed project yourself by specifying `headless: false` in the config, or by providing the `--browser.headless=false` flag, or by filtering projects with `--project=chromium` flag.
For more examples, refer to the ["Multiple Setups" guide](/guide/browser/multiple-setups).
:::
@ -94,8 +89,6 @@ List of available `browser` options:
- [`browser.screenshotFailures`](#browser-screenshotfailures)
- [`browser.provider`](#browser-provider)
By default, Vitest creates an array with a single element which uses the [`browser.name`](#browser-name) field as a `browser`. Note that this behaviour will be removed with Vitest 4.
Under the hood, Vitest transforms these instances into separate [test projects](/advanced/api/test-project) sharing a single Vite server for better caching performance.
## browser.headless
@ -134,12 +127,12 @@ Configure options for Vite server that serves code in the browser. Does not affe
- **Default:** `'preview'`
- **CLI:** `--browser.provider=playwright`
The return value of the provider factory. You can import the factory from `@vitest/browser/providers/<provider-name>` or make your own provider:
The return value of the provider factory. You can import the factory from `@vitest/browser-<provider-name>` or make your own provider:
```ts{8-10}
import { playwright } from '@vitest/browser/providers/playwright'
import { webdriverio } from '@vitest/browser/providers/webdriverio'
import { preview } from '@vitest/browser/providers/preview'
import { playwright } from '@vitest/browser-playwright'
import { webdriverio } from '@vitest/browser-webdriverio'
import { preview } from '@vitest/browser-preview'
export default defineConfig({
test: {
@ -155,7 +148,7 @@ export default defineConfig({
To configure how provider initializes the browser, you can pass down options to the factory function:
```ts{7-13,20-26}
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -294,7 +287,7 @@ export interface BrowserScript {
- **Type:** `Record<string, BrowserCommand>`
- **Default:** `{ readFile, writeFile, ... }`
Custom [commands](/guide/browser/commands) that can be imported during browser tests from `@vitest/browser/commands`.
Custom [commands](/guide/browser/commands) that can be imported during browser tests from `vitest/browser`.
## browser.connectTimeout

View File

@ -4,7 +4,7 @@ title: Context API | Browser Mode
# Context API
Vitest exposes a context module via `@vitest/browser/context` entry point. As of 2.0, it exposes a small set of utilities that might be useful to you in tests.
Vitest exposes a context module via `vitest/browser` entry point. As of 2.0, it exposes a small set of utilities that might be useful to you in tests.
## `userEvent`

View File

@ -99,7 +99,7 @@ To activate browser mode in your Vitest configuration, set the `browser.enabled`
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -127,7 +127,7 @@ If you have not used Vite before, make sure you have your framework's plugin ins
```ts [react]
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [react()],
@ -144,7 +144,7 @@ export default defineConfig({
```
```ts [vue]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
@ -163,7 +163,7 @@ export default defineConfig({
```ts [svelte]
import { defineConfig } from 'vitest/config'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [svelte()],
@ -181,7 +181,7 @@ export default defineConfig({
```ts [solid]
import { defineConfig } from 'vitest/config'
import solidPlugin from 'vite-plugin-solid'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [solidPlugin()],
@ -199,7 +199,7 @@ export default defineConfig({
```ts [marko]
import { defineConfig } from 'vitest/config'
import marko from '@marko/vite'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [marko()],
@ -217,7 +217,7 @@ export default defineConfig({
```ts [qwik]
import { defineConfig } from 'vitest/config'
import { qwikVite } from '@builder.io/qwik/optimizer'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
// optional, run the tests in SSR mode
import { testSSR } from 'vitest-browser-qwik/ssr-plugin'
@ -241,7 +241,7 @@ If you need to run some tests using Node-based runner, you can define a [`projec
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -338,7 +338,7 @@ Here's an example configuration enabling headless mode:
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -369,7 +369,7 @@ By default, you don't need any external packages to work with the Browser Mode:
```js [example.test.js]
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
import { render } from './my-render-function.js'
test('properly handles form inputs', async () => {
@ -407,15 +407,15 @@ Besides rendering components and locating elements, you will also need to make a
```ts
import { expect } from 'vitest'
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
// element is rendered correctly
await expect.element(page.getByText('Hello World')).toBeInTheDocument()
```
Vitest exposes a [Context API](/guide/browser/context) with a small set of utilities that might be useful to you in tests. For example, if you need to make an interaction, like clicking an element or typing text into an input, you can use `userEvent` from `@vitest/browser/context`. Read more at the [Interactivity API](/guide/browser/interactivity-api).
Vitest exposes a [Context API](/guide/browser/context) with a small set of utilities that might be useful to you in tests. For example, if you need to make an interaction, like clicking an element or typing text into an input, you can use `userEvent` from `vitest/browser`. Read more at the [Interactivity API](/guide/browser/interactivity-api).
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
await userEvent.fill(page.getByLabelText(/username/i), 'Alice')
// or just locator.fill
await page.getByLabelText(/username/i).fill('Alice')
@ -532,7 +532,7 @@ For unsupported frameworks, we recommend using `testing-library` packages:
You can also see more examples in [`browser-examples`](https://github.com/vitest-tests/browser-examples) repository.
::: warning
`testing-library` provides a package `@testing-library/user-event`. We do not recommend using it directly because it simulates events instead of actually triggering them - instead, use [`userEvent`](/guide/browser/interactivity-api) imported from `@vitest/browser/context` that uses Chrome DevTools Protocol or Webdriver (depending on the provider) under the hood.
`testing-library` provides a package `@testing-library/user-event`. We do not recommend using it directly because it simulates events instead of actually triggering them - instead, use [`userEvent`](/guide/browser/interactivity-api) imported from `vitest/browser` that uses Chrome DevTools Protocol or Webdriver (depending on the provider) under the hood.
:::
::: code-group

View File

@ -7,7 +7,7 @@ title: Interactivity API | Browser Mode
Vitest implements a subset of [`@testing-library/user-event`](https://testing-library.com/docs/user-event/intro) APIs using [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) or [webdriver](https://www.w3.org/TR/webdriver/) instead of faking events which makes the browser behaviour more reliable and consistent with how users interact with a page.
```ts
import { userEvent } from '@vitest/browser/context'
import { userEvent } from 'vitest/browser'
await userEvent.click(document.querySelector('.button'))
```
@ -23,10 +23,10 @@ function setup(): UserEvent
Creates a new user event instance. This is useful if you need to keep the state of keyboard to press and release buttons correctly.
::: warning
Unlike `@testing-library/user-event`, the default `userEvent` instance from `@vitest/browser/context` is created once, not every time its methods are called! You can see the difference in how it works in this snippet:
Unlike `@testing-library/user-event`, the default `userEvent` instance from `vitest/browser` is created once, not every time its methods are called! You can see the difference in how it works in this snippet:
```ts
import { userEvent as vitestUserEvent } from '@vitest/browser/context'
import { userEvent as vitestUserEvent } from 'vitest/browser'
import { userEvent as originalUserEvent } from '@testing-library/user-event'
await vitestUserEvent.keyboard('{Shift}') // press shift without releasing
@ -51,7 +51,7 @@ function click(
Click on an element. Inherits provider's options. Please refer to your provider's documentation for detailed explanation about how this method works.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('clicks on an element', async () => {
const logo = page.getByRole('img', { name: /logo/ })
@ -82,7 +82,7 @@ Triggers a double click event on an element.
Please refer to your provider's documentation for detailed explanation about how this method works.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('triggers a double click on an element', async () => {
const logo = page.getByRole('img', { name: /logo/ })
@ -113,7 +113,7 @@ Triggers a triple click event on an element. Since there is no `tripleclick` in
Please refer to your provider's documentation for detailed explanation about how this method works.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('triggers a triple click on an element', async () => {
const logo = page.getByRole('img', { name: /logo/ })
@ -150,7 +150,7 @@ function fill(
Set a value to the `input`/`textarea`/`contenteditable` field. This will remove any existing text in the input before setting the new value.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('update input', async () => {
const input = page.getByRole('input')
@ -189,7 +189,7 @@ The `userEvent.keyboard` allows you to trigger keyboard strokes. If any input ha
This API supports [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard).
```ts
import { userEvent } from '@vitest/browser/context'
import { userEvent } from 'vitest/browser'
test('trigger keystrokes', async () => {
await userEvent.keyboard('foo') // translates to: f, o, o
@ -215,7 +215,7 @@ function tab(options?: UserEventTabOptions): Promise<void>
Sends a `Tab` key event. This is a shorthand for `userEvent.keyboard('{tab}')`.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('tab works', async () => {
const [input1, input2] = page.getByRole('input').elements()
@ -259,7 +259,7 @@ This function allows you to type characters into an `input`/`textarea`/`contente
If you just need to press characters without an input, use [`userEvent.keyboard`](#userevent-keyboard) API.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('update input', async () => {
const input = page.getByRole('input')
@ -289,7 +289,7 @@ function clear(element: Element | Locator, options?: UserEventClearOptions): Pro
This method clears the input element content.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('clears input', async () => {
const input = page.getByRole('input')
@ -336,7 +336,7 @@ Unlike `@testing-library`, Vitest doesn't support [listbox](https://developer.mo
:::
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('clears input', async () => {
const select = page.getByRole('select')
@ -386,7 +386,7 @@ If you are using `playwright` provider, the cursor moves to "some" visible point
:::
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('hovers logo element', async () => {
const logo = page.getByRole('img', { name: /logo/ })
@ -419,7 +419,7 @@ By default, the cursor position is in "some" visible place (in `playwright` prov
:::
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('unhover logo element', async () => {
const logo = page.getByRole('img', { name: /logo/ })
@ -449,7 +449,7 @@ function upload(
Change a file input element to have the specified files.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('can upload a file', async () => {
const input = page.getByRole('button', { name: /Upload files/ })
@ -488,7 +488,7 @@ function dragAndDrop(
Drags the source element on top of the target element. Don't forget that the `source` element has to have the `draggable` attribute set to `true`.
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('drag and drop works', async () => {
const source = page.getByRole('img', { name: /logo/ })
@ -520,7 +520,7 @@ function copy(): Promise<void>
Copy the selected text to the clipboard.
```js
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('copy and paste', async () => {
// write to 'source'
@ -553,7 +553,7 @@ function cut(): Promise<void>
Cut the selected text to the clipboard.
```js
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
test('copy and paste', async () => {
// write to 'source'

View File

@ -609,7 +609,7 @@ function click(options?: UserEventClickOptions): Promise<void>
Click on an element. You can use the options to set the cursor position.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('img', { name: 'Rose' }).click()
```
@ -625,7 +625,7 @@ function dblClick(options?: UserEventDoubleClickOptions): Promise<void>
Triggers a double click event on an element. You can use the options to set the cursor position.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('img', { name: 'Rose' }).dblClick()
```
@ -641,7 +641,7 @@ function tripleClick(options?: UserEventTripleClickOptions): Promise<void>
Triggers a triple click event on an element. Since there is no `tripleclick` in browser api, this method will fire three click events in a row.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('img', { name: 'Rose' }).tripleClick()
```
@ -657,7 +657,7 @@ function clear(options?: UserEventClearOptions): Promise<void>
Clears the input element content.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('textbox', { name: 'Full Name' }).clear()
```
@ -673,7 +673,7 @@ function hover(options?: UserEventHoverOptions): Promise<void>
Moves the cursor position to the selected element.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('img', { name: 'Rose' }).hover()
```
@ -689,7 +689,7 @@ function unhover(options?: UserEventHoverOptions): Promise<void>
This works the same as [`locator.hover`](#hover), but moves the cursor to the `document.body` element instead.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('img', { name: 'Rose' }).unhover()
```
@ -705,7 +705,7 @@ function fill(text: string, options?: UserEventFillOptions): Promise<void>
Sets the value of the current `input`, `textarea` or `contenteditable` element.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
await page.getByRole('input', { name: 'Full Name' }).fill('Mr. Bean')
```
@ -724,7 +724,7 @@ function dropTo(
Drags the current element to the target location.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
const paris = page.getByText('Paris')
const france = page.getByText('France')
@ -752,7 +752,7 @@ function selectOptions(
Choose one or more values from a `<select>` element.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
const languages = page.getByRole('select', { name: 'Languages' })
@ -784,7 +784,7 @@ You can specify the save location for the screenshot using the `path` option, wh
If you also need the content of the screenshot, you can specify `base64: true` to return it alongside the filepath where the screenshot is saved.
```ts
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
const button = page.getByRole('button', { name: 'Click Me!' })
@ -946,7 +946,7 @@ const test: BrowserCommand<string> = function test(context, selector) {
```ts [example.test.ts]
import { test } from 'vitest'
import { commands, page } from '@vitest/browser/context'
import { commands, page } from 'vitest/browser'
test('works correctly', async () => {
await commands.test(page.getByText('Hello').selector) // ✅
@ -988,7 +988,7 @@ The selector syntax is identical to Playwright locators. Please, read [their gui
:::
```ts
import { locators } from '@vitest/browser/context'
import { locators } from 'vitest/browser'
locators.extend({
getByArticleTitle(title) {
@ -1010,7 +1010,7 @@ locators.extend({
// if you are using typescript, you can extend LocatorSelectors interface
// to have the autocompletion in locators.extend, page.* and locator.* methods
declare module '@vitest/browser/context' {
declare module 'vitest/browser' {
interface LocatorSelectors {
// if the custom method returns a string, it will be converted into a locator
// if it returns anything else, then it will be returned as usual

View File

@ -1,6 +1,6 @@
# Multiple Setups
Since Vitest 3, you can specify several different browser setups using the new [`browser.instances`](/guide/browser/config#browser-instances) option.
You can specify several different browser setups using the [`browser.instances`](/guide/browser/config#browser-instances) option.
The main advantage of using the `browser.instances` over the [test projects](/guide/projects) is improved caching. Every project will use the same Vite server meaning the file transform and [dependency pre-bundling](https://vite.dev/guide/dep-pre-bundling.html) has to happen only once.
@ -10,7 +10,7 @@ You can use the `browser.instances` field to specify options for different brows
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -35,7 +35,7 @@ You can also specify different config options independently from the browser (al
::: code-group
```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -50,14 +50,14 @@ export default defineConfig({
setupFiles: ['./ratio-setup.ts'],
provide: {
ratio: 1,
}
},
},
{
browser: 'chromium',
name: 'chromium-2',
provide: {
ratio: 2,
}
},
},
],
},
@ -119,20 +119,3 @@ export default defineConfig({
})
```
:::
::: warning
Vitest cannot run multiple instances that have `headless` mode set to `false` (the default behaviour). During development, you can select what project to run in your terminal:
```shell
? Found multiple projects that run browser tests in headed mode: "chromium", "firefox".
Vitest cannot run multiple headed browsers at the same time. Select a single project
to run or cancel and run tests with "headless: true" option. Note that you can also
start tests with --browser=name or --project=name flag. - Use arrow-keys. Return to submit.
chromium
firefox
```
If you have several non-headless projects in CI (i.e. the `headless: false` is set manually in the config and not overridden in CI env), Vitest will fail the run and won't start any tests.
The ability to run tests in headless mode is not affected by this. You can still run all instances in parallel as long as they don't have `headless: false`.
:::

View File

@ -1,9 +1,9 @@
# Configuring Playwright
To run tests using playwright, you need to specify it in the `test.browser.provider` property in your config:
To run tests using playwright, you need to install the [`@vitest/browser-playwright`](https://www.npmjs.com/package/@vitest/browser-playwright) npm package and specify its `playwright` export in the `test.browser.provider` property of your config:
```ts [vitest.config.js]
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -16,10 +16,10 @@ export default defineConfig({
})
```
Vitest opens a single page to run all tests in the same file. You can configure the `launch`, `connect` and `context` when calling `playwright` at the top level or inside instances:
You can configure the [`launchOptions`](https://playwright.dev/docs/api/class-browsertype#browser-type-launch), [`connectOptions`](https://playwright.dev/docs/api/class-browsertype#browser-type-connect) and [`contextOptions`](https://playwright.dev/docs/api/class-browser#browser-new-context) when calling `playwright` at the top level or inside instances:
```ts{7-14,21-26} [vitest.config.js]
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -53,6 +53,10 @@ export default defineConfig({
})
```
::: warning
Unlike Playwright test runner, Vitest opens a _single_ page to run all tests that are defined in the same file. This means that isolation is restricted to a single test file, not to every individual test.
:::
## launchOptions
These options are directly passed down to `playwright[browser].launch` command. You can read more about the command and available arguments in the [Playwright documentation](https://playwright.dev/docs/api/class-browsertype#browser-type-launch).
@ -94,7 +98,7 @@ This value configures the default timeout it takes for Playwright to wait until
You can also configure the action timeout per-action:
```ts
import { page, userEvent } from '@vitest/browser/context'
import { page, userEvent } from 'vitest/browser'
await userEvent.click(page.getByRole('button'), {
timeout: 1_000,

View File

@ -0,0 +1,32 @@
# Configuring Preview
::: warning
The `preview` provider's main functionality is to show tests in a real browser environment. However, it does not support advanced browser automation features like multiple browser instances or headless mode. For more complex scenarios, consider using [Playwright](/guide/browser/playwright) or [WebdriverIO](/guide/browser/webdriverio).
:::
To see your tests running in a real browser, you need to install the [`@vitest/browser-preview`](https://www.npmjs.com/package/@vitest/browser-preview) npm package and specify its `preview` export in the `test.browser.provider` property of your config:
```ts [vitest.config.js]
import { preview } from '@vitest/browser-preview'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
provider: preview(),
instances: [{ browser: 'chromium' }]
},
},
})
```
This will open a new browser window using your default browser to run the tests. You can configure which browser to use by setting the `browser` property in the `instances` array. Vitest will try to open that browser automatically, but it might not work in some environments. In that case, you can manually open the provided URL in your desired browser.
## Differences with Other Providers
The preview provider has some limitations compared to other providers like [Playwright](/guide/browser/playwright) or [WebdriverIO](/guide/browser/webdriverio):
- It does not support headless mode; the browser window will always be visible.
- It does not support multiple instances of the same browser; each instance must use a different browser.
- It does not support advanced browser capabilities or options; you can only specify the browser name.
- It does not support CDP (Chrome DevTools Protocol) commands or other low-level browser interactions. Unlike Playwright or WebdriverIO, the [`userEvent`](/guide/browser/interactivity-api) API is just re-exported from [`@testing-library/user-event`](https://www.npmjs.com/package/@testing-library/user-event) and does not have any special integration with the browser.

View File

@ -9,7 +9,7 @@ Generating trace files is only available when using the [Playwright provider](/g
::: code-group
```ts [vitest.config.js]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
@ -39,7 +39,7 @@ To change the output directory, you can set the `tracesDir` option in the `test.
```ts [vitest.config.js]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {

View File

@ -54,7 +54,7 @@ Visual regression testing in Vitest can be done through the
```ts
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import { page } from 'vitest/browser'
test('hero section looks correct', async () => {
// ...the rest of the test
@ -239,7 +239,7 @@ await page.viewport(1280, 720)
```
```ts [vitest.config.ts]
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -592,7 +592,7 @@ The cleanest approach is using [Test Projects](/guide/projects):
```ts [vitest.config.ts]
import { env } from 'node:process'
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
// ...global Vite config

View File

@ -4,10 +4,10 @@
If you do not already use WebdriverIO in your project, we recommend starting with [Playwright](/guide/browser/playwright) as it is easier to configure and has more flexible API.
:::
To run tests using WebdriverIO, you need to specify it in the `test.browser.provider` property in your config:
To run tests using WebdriverIO, you need to install the [`@vitest/browser-webdriverio`](https://www.npmjs.com/package/@vitest/browser-webdriverio) npm package and specify its `webdriverio` export in the `test.browser.provider` property of your config:
```ts [vitest.config.js]
import { webdriverio } from '@vitest/browser/providers/webdriverio'
import { webdriverio } from '@vitest/browser-webdriverio'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -20,10 +20,10 @@ export default defineConfig({
})
```
Vitest opens a single page to run all tests in the same file. You can configure all the parameters that [`remote`](https://webdriver.io/docs/api/modules/#remoteoptions-modifier) function accepts:
You can configure all the parameters that [`remote`](https://webdriver.io/docs/api/modules/#remoteoptions-modifier) function accepts:
```ts{8-12,19-23} [vitest.config.js]
import { webdriverio } from '@vitest/browser/providers/webdriverio'
```ts{8-12,19-25} [vitest.config.js]
import { webdriverio } from '@vitest/browser-webdriverio'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@ -42,11 +42,13 @@ export default defineConfig({
// overriding options only for a single instance
// this will NOT merge options with the parent one
provider: webdriverio({
'moz:firefoxOptions': {
args: ['--disable-gpu'],
capabilities: {
'moz:firefoxOptions': {
args: ['--disable-gpu'],
},
},
})
}
},
],
},
},
@ -58,5 +60,5 @@ You can find most available options in the [WebdriverIO documentation](https://w
::: tip
Most useful options are located on `capabilities` object. WebdriverIO allows nested capabilities, but Vitest will ignore those options because we rely on a different mechanism to spawn several browsers.
Note that Vitest will ignore `capabilities.browserName`. Use [`test.browser.instances.browser`](/guide/browser/config#browser-capabilities-name) instead.
Note that Vitest will ignore `capabilities.browserName` — use [`test.browser.instances.browser`](/guide/browser/config#browser-capabilities-name) instead.
:::

View File

@ -52,7 +52,7 @@ vitest --inspect-brk --browser --no-file-parallelism
```
```ts [vitest.config.js]
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {

View File

@ -215,12 +215,12 @@ export default defineWorkspace([ // [!code --]
```
:::
### Browser Provider Accepts an Object
### Browser Provider Rework
In Vitest 4.0, the browser provider now accepts an object instead of a string (`'playwright'`, `'webdriverio'`). This makes it simpler to work with custom options and doesn't require adding `/// <reference` comments anymore.
In Vitest 4.0, the browser provider now accepts an object instead of a string (`'playwright'`, `'webdriverio'`). The `preview` is no longer a default. This makes it simpler to work with custom options and doesn't require adding `/// <reference` comments anymore.
```ts
import { playwright } from '@vitest/browser/providers/playwright' // [!code ++]
import { playwright } from '@vitest/browser-playwright' // [!code ++]
export default defineConfig({
test: {
@ -246,6 +246,31 @@ export default defineConfig({
The naming of properties in `playwright` factory now also aligns with [Playwright documentation](https://playwright.dev/docs/api/class-testoptions#test-options-launch-options) making it easier to find.
With this change, the `@vitest/browser` package is no longer needed, and you can remove it from your dependencies. To support the context import, you should update the `@vitest/browser/context` to `vitest/browser`:
```ts
import { page } from '@vitest/browser/context' // [!code --]
import { page } from 'vitest/browser' // [!code ++]
test('example', async () => {
await page.getByRole('button').click()
})
```
The modules are identical, so doing a simple "Find and Replace" should be sufficient.
If you were using the `@vitest/browser/utils` module, you can now import those utilities from `vitest/browser` as well:
```ts
import { getElementError } from '@vitest/browser/utils' // [!code --]
import { utils } from 'vitest/browser' // [!code ++]
const { getElementError } = utils // [!code ++]
```
::: warning
Both `@vitest/browser/context` and `@vitest/browser/utils` work at runtime during the transition period, but they will be removed in a future release.
:::
### Reporter Updates
Reporter APIs `onCollected`, `onSpecsCollected`, `onPathsCollected`, `onTaskUpdate` and `onFinished` were removed. See [`Reporters API`](/advanced/api/reporters) for new alternatives. The new APIs were introduced in Vitest `v3.0.0`.
@ -277,7 +302,7 @@ export default defineConfig({
In Vitest 4.0 snapshots that include custom elements will print the shadow root contents. To restore the previous behavior, set the [`printShadowRoot` option](/config/#snapshotformat) to `false`.
```js
```js{15-22}
// before Vite 4.0
exports[`custom element with shadow root 1`] = `
"<body>

View File

@ -89,7 +89,7 @@ export default antfu(
},
{
// these files define vitest as peer dependency
files: [`packages/{coverage-*,ui,browser,web-worker}/${GLOB_SRC}`],
files: [`packages/{coverage-*,ui,browser,web-worker,browser-*}/${GLOB_SRC}`],
rules: {
'no-restricted-imports': [
'error',

View File

@ -17,7 +17,7 @@
"lit": "^3.3.1"
},
"devDependencies": {
"@vitest/browser": "latest",
"@vitest/browser-playwright": "latest",
"jsdom": "latest",
"playwright": "^1.55.0",
"vite": "latest",

View File

@ -1,5 +1,5 @@
import { page } from '@vitest/browser/context'
import { beforeEach, describe, expect, it } from 'vitest'
import { page } from 'vitest/browser'
import '../src/my-button.js'

View File

@ -5,7 +5,6 @@
"experimentalDecorators": true,
"module": "node16",
"moduleResolution": "Node16",
"types": ["@vitest/browser/providers/playwright"],
"verbatimModuleSyntax": true
}
}

View File

@ -1,6 +1,6 @@
/// <reference types="vitest/config" />
import { playwright } from '@vitest/browser/providers/playwright'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/

View File

@ -71,6 +71,9 @@
"pnpm": {
"overrides": {
"@vitest/browser": "workspace:*",
"@vitest/browser-playwright": "workspace:*",
"@vitest/browser-preview": "workspace:*",
"@vitest/browser-webdriverio": "workspace:*",
"@vitest/ui": "workspace:*",
"acorn": "8.11.3",
"mlly": "^1.8.0",

View File

@ -0,0 +1,48 @@
# @vitest/browser-playwright
[![NPM version](https://img.shields.io/npm/v/@vitest/browser-playwright?color=a1b858&label=)](https://www.npmjs.com/package/@vitest/browser-playwright)
Run your Vitest [browser tests](https://vitest.dev/guide/browser/) using [playwright](https://playwright.dev/docs/api/class-playwright) API. Note that Vitest does not use playwright as a test runner, but only as a browser provider.
We recommend using this package if you are already using playwright in your project or if you do not have any E2E tests yet.
## Installation
Install the package with your favorite package manager:
```sh
npm install -D @vitest/browser-playwright
# or
yarn add -D @vitest/browser-playwright
# or
pnpm add -D @vitest/browser-playwright
```
Then specify it in the `browser.provider` field of your Vitest configuration:
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
provider: playwright({
// ...custom playwright options
}),
instances: [
{ name: 'chromium' },
],
},
},
})
```
Then run Vitest in the browser mode:
```sh
npx vitest --browser
```
[GitHub](https://github.com/vitest-dev/vitest/tree/main/packages/browser-playwright) | [Documentation](https://vitest.dev/guide/browser/playwright)

View File

@ -0,0 +1 @@
export * from '@vitest/browser/context'

View File

@ -0,0 +1,58 @@
{
"name": "@vitest/browser-playwright",
"type": "module",
"version": "4.0.0-beta.13",
"description": "Browser running for Vitest using playwright",
"license": "MIT",
"funding": "https://opencollective.com/vitest",
"homepage": "https://vitest.dev/guide/browser/playwright",
"repository": {
"type": "git",
"url": "git+https://github.com/vitest-dev/vitest.git",
"directory": "packages/browser-playwright"
},
"bugs": {
"url": "https://github.com/vitest-dev/vitest/issues"
},
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./context": {
"types": "./context.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"context.d.ts",
"dist"
],
"scripts": {
"build": "premove dist && pnpm rollup -c",
"dev": "rollup -c --watch --watch.include 'src/**'"
},
"peerDependencies": {
"playwright": "*",
"vitest": "workspace:*"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
},
"dependencies": {
"@vitest/browser": "workspace:*",
"@vitest/mocker": "workspace:*",
"tinyrainbow": "catalog:"
},
"devDependencies": {
"playwright": "^1.55.0",
"playwright-core": "^1.55.0",
"vitest": "workspace:*"
}
}

View File

@ -0,0 +1,62 @@
import { createRequire } from 'node:module'
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import { defineConfig } from 'rollup'
import oxc from 'unplugin-oxc/rollup'
import { createDtsUtils } from '../../scripts/build-utils.js'
const require = createRequire(import.meta.url)
const pkg = require('./package.json')
const external = [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies || {}),
/^@?vitest(\/|$)/,
'vite',
'playwright-core/types/protocol',
]
const dtsUtils = createDtsUtils()
const plugins = [
resolve({
preferBuiltins: true,
}),
json(),
commonjs(),
oxc({
transform: { target: 'node18' },
}),
]
export default () =>
defineConfig([
{
input: {
index: './src/index.ts',
locators: './src/locators.ts',
},
output: {
dir: 'dist',
format: 'esm',
},
external,
context: 'null',
plugins: [
...dtsUtils.isolatedDecl(),
...plugins,
],
},
{
input: dtsUtils.dtsInput('src/index.ts'),
output: {
dir: 'dist',
entryFileNames: '[name].d.ts',
format: 'esm',
},
watch: false,
external,
plugins: dtsUtils.dts(),
},
])

View File

@ -0,0 +1,11 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const clear: UserEventCommand<UserEvent['clear']> = async (
context,
selector,
) => {
const { iframe } = context
const element = iframe.locator(selector)
await element.clear()
}

View File

@ -0,0 +1,32 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const click: UserEventCommand<UserEvent['click']> = async (
context,
selector,
options = {},
) => {
const tester = context.iframe
await tester.locator(selector).click(options)
}
export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (
context,
selector,
options = {},
) => {
const tester = context.iframe
await tester.locator(selector).dblclick(options)
}
export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async (
context,
selector,
options = {},
) => {
const tester = context.iframe
await tester.locator(selector).click({
...options,
clickCount: 3,
})
}

View File

@ -0,0 +1,16 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const dragAndDrop: UserEventCommand<UserEvent['dragAndDrop']> = async (
context,
source,
target,
options_,
) => {
const frame = await context.frame()
await frame.dragAndDrop(
source,
target,
options_,
)
}

View File

@ -0,0 +1,13 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const fill: UserEventCommand<UserEvent['fill']> = async (
context,
selector,
text,
options = {},
) => {
const { iframe } = context
const element = iframe.locator(selector)
await element.fill(text, options)
}

View File

@ -0,0 +1,10 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const hover: UserEventCommand<UserEvent['hover']> = async (
context,
selector,
options = {},
) => {
await context.iframe.locator(selector).hover(options)
}

View File

@ -0,0 +1,40 @@
import { clear } from './clear'
import { click, dblClick, tripleClick } from './click'
import { dragAndDrop } from './dragAndDrop'
import { fill } from './fill'
import { hover } from './hover'
import { keyboard, keyboardCleanup } from './keyboard'
import { takeScreenshot } from './screenshot'
import { selectOptions } from './select'
import { tab } from './tab'
import {
annotateTraces,
deleteTracing,
startChunkTrace,
startTracing,
stopChunkTrace,
} from './trace'
import { type } from './type'
import { upload } from './upload'
export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
__vitest_fill: fill as typeof fill,
__vitest_tab: tab as typeof tab,
__vitest_keyboard: keyboard as typeof keyboard,
__vitest_selectOptions: selectOptions as typeof selectOptions,
__vitest_dragAndDrop: dragAndDrop as typeof dragAndDrop,
__vitest_hover: hover as typeof hover,
__vitest_cleanup: keyboardCleanup as typeof keyboardCleanup,
__vitest_deleteTracing: deleteTracing as typeof deleteTracing,
__vitest_startChunkTrace: startChunkTrace as typeof startChunkTrace,
__vitest_startTracing: startTracing as typeof startTracing,
__vitest_stopChunkTrace: stopChunkTrace as typeof stopChunkTrace,
__vitest_annotateTraces: annotateTraces as typeof annotateTraces,
}

View File

@ -0,0 +1,133 @@
import type { BrowserProvider } from 'vitest/node'
import type { PlaywrightBrowserProvider } from '../playwright'
import type { UserEventCommand } from './utils'
import { parseKeyDef } from '@vitest/browser'
export interface KeyboardState {
unreleased: string[]
}
export const keyboard: UserEventCommand<(text: string, state: KeyboardState) => Promise<{ unreleased: string[] }>> = async (
context,
text,
state,
) => {
const frame = await context.frame()
await frame.evaluate(focusIframe)
const pressed = new Set<string>(state.unreleased)
await keyboardImplementation(
pressed,
context.provider,
context.sessionId,
text,
async () => {
const frame = await context.frame()
await frame.evaluate(selectAll)
},
true,
)
return {
unreleased: Array.from(pressed),
}
}
export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise<void>> = async (
context,
state,
) => {
const { provider, sessionId } = context
if (!state.unreleased) {
return
}
const page = (provider as PlaywrightBrowserProvider).getPage(sessionId)
for (const key of state.unreleased) {
await page.keyboard.up(key)
}
}
// fallback to insertText for non US key
// https://github.com/microsoft/playwright/blob/50775698ae13642742f2a1e8983d1d686d7f192d/packages/playwright-core/src/server/input.ts#L95
const VALID_KEYS = new Set(['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'Backquote', '`', '~', 'Digit1', '1', '!', 'Digit2', '2', '@', 'Digit3', '3', '#', 'Digit4', '4', '$', 'Digit5', '5', '%', 'Digit6', '6', '^', 'Digit7', '7', '&', 'Digit8', '8', '*', 'Digit9', '9', '(', 'Digit0', '0', ')', 'Minus', '-', '_', 'Equal', '=', '+', 'Backslash', '\\', '|', 'Backspace', 'Tab', 'KeyQ', 'q', 'Q', 'KeyW', 'w', 'W', 'KeyE', 'e', 'E', 'KeyR', 'r', 'R', 'KeyT', 't', 'T', 'KeyY', 'y', 'Y', 'KeyU', 'u', 'U', 'KeyI', 'i', 'I', 'KeyO', 'o', 'O', 'KeyP', 'p', 'P', 'BracketLeft', '[', '{', 'BracketRight', ']', '}', 'CapsLock', 'KeyA', 'a', 'A', 'KeyS', 's', 'S', 'KeyD', 'd', 'D', 'KeyF', 'f', 'F', 'KeyG', 'g', 'G', 'KeyH', 'h', 'H', 'KeyJ', 'j', 'J', 'KeyK', 'k', 'K', 'KeyL', 'l', 'L', 'Semicolon', ';', ':', 'Quote', '\'', '"', 'Enter', '\n', '\r', 'ShiftLeft', 'Shift', 'KeyZ', 'z', 'Z', 'KeyX', 'x', 'X', 'KeyC', 'c', 'C', 'KeyV', 'v', 'V', 'KeyB', 'b', 'B', 'KeyN', 'n', 'N', 'KeyM', 'm', 'M', 'Comma', ',', '<', 'Period', '.', '>', 'Slash', '/', '?', 'ShiftRight', 'ControlLeft', 'Control', 'MetaLeft', 'Meta', 'AltLeft', 'Alt', 'Space', ' ', 'AltRight', 'AltGraph', 'MetaRight', 'ContextMenu', 'ControlRight', 'PrintScreen', 'ScrollLock', 'Pause', 'PageUp', 'PageDown', 'Insert', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'NumLock', 'NumpadDivide', 'NumpadMultiply', 'NumpadSubtract', 'Numpad7', 'Numpad8', 'Numpad9', 'Numpad4', 'Numpad5', 'Numpad6', 'NumpadAdd', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad0', 'NumpadDecimal', 'NumpadEnter', 'ControlOrMeta'])
export async function keyboardImplementation(
pressed: Set<string>,
provider: BrowserProvider,
sessionId: string,
text: string,
selectAll: () => Promise<void>,
skipRelease: boolean,
): Promise<{ pressed: Set<string> }> {
const page = (provider as PlaywrightBrowserProvider).getPage(sessionId)
const actions = parseKeyDef(text)
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)) {
if (VALID_KEYS.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++) {
if (VALID_KEYS.has(key)) {
await page.keyboard.down(key)
}
else {
await page.keyboard.insertText(key)
}
}
if (releaseSelf) {
if (VALID_KEYS.has(key)) {
await page.keyboard.up(key)
}
}
else {
pressed.add(key)
}
}
}
if (!skipRelease && pressed.size) {
for (const key of pressed) {
if (VALID_KEYS.has(key)) {
await page.keyboard.up(key)
}
}
}
return {
pressed,
}
}
function focusIframe() {
if (
!document.activeElement
|| document.activeElement.ownerDocument !== document
|| document.activeElement === document.body
) {
window.focus()
}
}
function selectAll() {
const element = document.activeElement as HTMLInputElement
if (element && typeof element.select === 'function') {
element.select()
}
}

View File

@ -0,0 +1,63 @@
import type { ScreenshotOptions } from 'vitest/browser'
import type { BrowserCommandContext } from 'vitest/node'
import { mkdir } from 'node:fs/promises'
import { resolveScreenshotPath } from '@vitest/browser'
import { dirname, normalize } from 'pathe'
interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> {
element?: string
mask?: readonly string[]
}
/**
* Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path.
*
* **Note**: the returned `path` indicates where the screenshot *might* be found.
* It is not guaranteed to exist, especially if `options.save` is `false`.
*
* @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots.
*/
export async function takeScreenshot(
context: BrowserCommandContext,
name: string,
options: Omit<ScreenshotCommandOptions, 'base64'>,
): Promise<{ buffer: Buffer<ArrayBufferLike>; path: string }> {
if (!context.testPath) {
throw new Error(`Cannot take a screenshot without a test path`)
}
const path = resolveScreenshotPath(
context.testPath,
name,
context.project.config,
options.path,
)
// playwright does not need a screenshot path if we don't intend to save it
let savePath: string | undefined
if (options.save) {
savePath = normalize(path)
await mkdir(dirname(savePath), { recursive: true })
}
const mask = options.mask?.map(selector => context.iframe.locator(selector))
if (options.element) {
const { element: selector, ...config } = options
const element = context.iframe.locator(selector)
const buffer = await element.screenshot({
...config,
mask,
path: savePath,
})
return { buffer, path }
}
const buffer = await context.iframe.locator('body').screenshot({
...options,
mask,
path: savePath,
})
return { buffer, path }
}

View File

@ -0,0 +1,27 @@
import type { ElementHandle } from 'playwright'
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const selectOptions: UserEventCommand<UserEvent['selectOptions']> = async (
context,
selector,
userValues,
options = {},
) => {
const value = userValues as any as (string | { element: string })[]
const { iframe } = context
const selectElement = iframe.locator(selector)
const values = await Promise.all(value.map(async (v) => {
if (typeof v === 'string') {
return v
}
const elementHandler = await iframe.locator(v.element).elementHandle()
if (!elementHandler) {
throw new Error(`Element not found: ${v.element}`)
}
return elementHandler
})) as (readonly string[]) | (readonly ElementHandle[])
await selectElement.selectOption(values, options)
}

View File

@ -0,0 +1,10 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const tab: UserEventCommand<UserEvent['tab']> = async (
context,
options = {},
) => {
const page = context.page
await page.keyboard.press(options.shift === true ? 'Shift+Tab' : 'Tab')
}

View File

@ -1,11 +1,10 @@
import type { BrowserCommandContext } from 'vitest/node'
import type { BrowserCommand } from '../plugin'
import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node'
import type { PlaywrightBrowserProvider } from '../playwright'
import { unlink } from 'node:fs/promises'
import { basename, dirname, relative, resolve } from 'pathe'
import { PlaywrightBrowserProvider } from '../providers/playwright'
export const startTracing: BrowserCommand<[]> = async ({ context, project, provider, sessionId }) => {
if (provider instanceof PlaywrightBrowserProvider) {
if (isPlaywrightProvider(provider)) {
if (provider.tracingContexts.has(sessionId)) {
return
}
@ -33,7 +32,7 @@ export const startChunkTrace: BrowserCommand<[{ name: string; title: string }]>
if (!testPath) {
throw new Error(`stopChunkTrace cannot be called outside of the test file.`)
}
if (provider instanceof PlaywrightBrowserProvider) {
if (isPlaywrightProvider(provider)) {
if (!provider.tracingContexts.has(sessionId)) {
await startTracing(command)
}
@ -49,7 +48,7 @@ export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async (
context,
{ name },
) => {
if (context.provider instanceof PlaywrightBrowserProvider) {
if (isPlaywrightProvider(context.provider)) {
const path = resolveTracesPath(context, name)
context.provider.pendingTraces.delete(path)
await context.context.tracing.stopChunk({ path })
@ -84,7 +83,7 @@ export const deleteTracing: BrowserCommand<[{ traces: string[] }]> = async (
if (!context.testPath) {
throw new Error(`stopChunkTrace cannot be called outside of the test file.`)
}
if (context.provider instanceof PlaywrightBrowserProvider) {
if (isPlaywrightProvider(context.provider)) {
return Promise.all(
traces.map(trace => unlink(trace).catch((err) => {
if (err.code === 'ENOENT') {
@ -124,3 +123,7 @@ export const annotateTraces: BrowserCommand<[{ traces: string[]; testId: string
})
}))
}
function isPlaywrightProvider(provider: BrowserProvider): provider is PlaywrightBrowserProvider {
return provider.name === 'playwright'
}

View File

@ -0,0 +1,33 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { keyboardImplementation } from './keyboard'
export const type: UserEventCommand<UserEvent['type']> = async (
context,
selector,
text,
options = {},
) => {
const { skipClick = false, skipAutoClose = false } = options
const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? [])
const { iframe } = context
const element = iframe.locator(selector)
if (!skipClick) {
await element.focus()
}
await keyboardImplementation(
unreleased,
context.provider,
context.sessionId,
text,
() => element.selectText(),
skipAutoClose,
)
return {
unreleased: Array.from(unreleased),
}
}

View File

@ -0,0 +1,33 @@
import type { UserEventUploadOptions } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { resolve } from 'pathe'
export const upload: UserEventCommand<(element: string, files: Array<string | {
name: string
mimeType: string
base64: string
}>, options: UserEventUploadOptions) => void> = async (
context,
selector,
files,
options,
) => {
const testPath = context.testPath
if (!testPath) {
throw new Error(`Cannot upload files outside of a test`)
}
const root = context.project.config.root
const { iframe } = context
const playwrightFiles = files.map((file) => {
if (typeof file === 'string') {
return resolve(root, file)
}
return {
name: file.name,
mimeType: file.mimeType,
buffer: Buffer.from(file.base64, 'base64'),
}
})
await iframe.locator(selector).setInputFiles(playwrightFiles as string[], options)
}

View File

@ -1,4 +1,4 @@
import type { Locator } from '@vitest/browser/context'
import type { Locator } from 'vitest/browser'
import type { BrowserCommand } from 'vitest/node'
export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<

View File

@ -0,0 +1,5 @@
import { fileURLToPath } from 'node:url'
import { resolve } from 'pathe'
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
export const distRoot: string = resolve(pkgRoot, 'dist')

View File

@ -0,0 +1,6 @@
export {
playwright,
PlaywrightBrowserProvider,
type PlaywrightProviderOptions,
} from './playwright'
export { defineBrowserCommand } from '@vitest/browser'

View File

@ -6,8 +6,7 @@ import type {
UserEventHoverOptions,
UserEventSelectOptions,
UserEventUploadOptions,
} from '@vitest/browser/context'
import { page, server } from '@vitest/browser/context'
} from 'vitest/browser'
import {
getByAltTextSelector,
getByLabelSelector,
@ -16,48 +15,13 @@ import {
getByTestIdSelector,
getByTextSelector,
getByTitleSelector,
} from 'ivya'
import { getIframeScale, processTimeoutOptions } from '../utils'
import { Locator, selectorEngine } from './index'
page.extend({
getByLabelText(text, options) {
return new PlaywrightLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new PlaywrightLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new PlaywrightLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new PlaywrightLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new PlaywrightLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new PlaywrightLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new PlaywrightLocator(getByTitleSelector(title, options))
},
_createLocator(selector: string) {
return new PlaywrightLocator(selector)
},
elementLocator(element: Element) {
return new PlaywrightLocator(
selectorEngine.generateSelectorSimple(element),
element,
)
},
frameLocator(locator: Locator) {
return new PlaywrightLocator(
`${locator.selector} >> internal:control=enter-frame`,
)
},
})
getIframeScale,
Locator,
processTimeoutOptions,
selectorEngine,
} from '@vitest/browser/locators'
import { page, server } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
class PlaywrightLocator extends Locator {
constructor(public selector: string, protected _container?: Element) {
@ -120,42 +84,71 @@ class PlaywrightLocator extends Locator {
}
}
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
if (!options_) {
return options_
page.extend({
getByLabelText(text, options) {
return new PlaywrightLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new PlaywrightLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new PlaywrightLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new PlaywrightLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new PlaywrightLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new PlaywrightLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new PlaywrightLocator(getByTitleSelector(title, options))
},
elementLocator(element: Element) {
return new PlaywrightLocator(
selectorEngine.generateSelectorSimple(element),
element,
)
},
frameLocator(locator: Locator) {
return new PlaywrightLocator(
`${locator.selector} >> internal:control=enter-frame`,
)
},
})
__INTERNAL._createLocator = selector => new PlaywrightLocator(selector)
function processDragAndDropOptions(options?: UserEventDragAndDropOptions) {
if (!options) {
return options
}
const options = options_ as NonNullable<
Parameters<import('playwright').Page['dragAndDrop']>[2]
>
if (options.sourcePosition) {
options.sourcePosition = processPlaywrightPosition(options.sourcePosition)
}
if (options.targetPosition) {
options.targetPosition = processPlaywrightPosition(options.targetPosition)
}
return options_
return options
}
function processHoverOptions(options_?: UserEventHoverOptions) {
if (!options_) {
return options_
function processHoverOptions(options?: UserEventHoverOptions) {
if (!options) {
return options
}
const options = options_ as NonNullable<
Parameters<import('playwright').Page['hover']>[1]
>
if (options.position) {
options.position = processPlaywrightPosition(options.position)
}
return options_
return options
}
function processClickOptions(options_?: UserEventClickOptions) {
if (!options_) {
return options_
function processClickOptions(options?: UserEventClickOptions) {
if (!options) {
return options
}
const options = options_ as NonNullable<
Parameters<import('playwright').Page['click']>[1]
>
if (options.position) {
options.position = processPlaywrightPosition(options.position)
}

View File

@ -1,9 +1,5 @@
/* eslint-disable ts/method-signature-style */
import type {
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
} from '@vitest/browser/context'
import type { MockedModule } from '@vitest/mocker'
import type {
Browser,
@ -19,15 +15,25 @@ import type { Protocol } from 'playwright-core/types/protocol'
import type { SourceMap } from 'rollup'
import type { ResolvedConfig } from 'vite'
import type {
Locator,
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
} from 'vitest/browser'
import type {
BrowserCommand,
BrowserModuleMocker,
BrowserProvider,
BrowserProviderOption,
CDPSession,
TestProject,
} from 'vitest/node'
import { defineBrowserProvider } from '@vitest/browser'
import { createManualModuleSource } from '@vitest/mocker/node'
import { resolve } from 'pathe'
import c from 'tinyrainbow'
import { createDebugger, isCSSRequest } from 'vitest/node'
import commands from './commands'
import { distRoot } from './constants'
const debug = createDebugger('vitest:browser:playwright')
@ -68,17 +74,14 @@ export interface PlaywrightProviderOptions {
}
export function playwright(options: PlaywrightProviderOptions = {}): BrowserProviderOption<PlaywrightProviderOptions> {
return {
return defineBrowserProvider({
name: 'playwright',
supportedBrowser: playwrightBrowsers,
options,
factory(project) {
providerFactory(project) {
return new PlaywrightBrowserProvider(project, options)
},
// --browser.provider=playwright
// @ts-expect-error hidden way to bypass importing playwright
_cli: true,
}
})
}
export class PlaywrightBrowserProvider implements BrowserProvider {
@ -98,6 +101,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
public tracingContexts: Set<string> = new Set()
public pendingTraces: Map<string, string> = new Map()
public initScripts: string[] = [
resolve(distRoot, 'locators.js'),
]
constructor(
private project: TestProject,
private options: PlaywrightProviderOptions,
@ -105,6 +112,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.browserName = project.config.browser.name as PlaywrightBrowser
this.mocker = this.createMocker()
for (const [name, command] of Object.entries(commands)) {
project.browser!.registerCommand(name as any, command as BrowserCommand)
}
// make sure the traces are finished if the test hangs
process.on('SIGTERM', () => {
if (!this.browser) {
@ -432,10 +443,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
return page
}
async openPage(sessionId: string, url: string, beforeNavigate?: () => Promise<void>): Promise<void> {
async openPage(sessionId: string, url: string): Promise<void> {
debug?.('[%s][%s] creating the browser page for %s', sessionId, this.browserName, url)
const browserPage = await this.openBrowserPage(sessionId)
await beforeNavigate?.()
debug?.('[%s][%s] browser page is created, opening %s', sessionId, this.browserName, url)
await browserPage.goto(url, { timeout: 0 })
await this._throwIfClosing(browserPage)
@ -538,6 +548,10 @@ declare module 'vitest/node' {
context: BrowserContext
}
export interface _BrowserNames {
playwright: PlaywrightBrowser
}
export interface ToMatchScreenshotOptions
extends Omit<
ScreenshotMatcherOptions,
@ -557,7 +571,7 @@ type PWSelectOptions = NonNullable<Parameters<Page['selectOption']>[2]>
type PWDragAndDropOptions = NonNullable<Parameters<Page['dragAndDrop']>[2]>
type PWSetInputFiles = NonNullable<Parameters<Page['setInputFiles']>[2]>
declare module '@vitest/browser/context' {
declare module 'vitest/browser' {
export interface UserEventHoverOptions extends PWHoverOptions {}
export interface UserEventClickOptions extends PWClickOptions {}
export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vite/client"],
"isolatedDeclarations": true
},
"exclude": [
"dist",
"node_modules",
"**/vite.config.ts",
"src/client/**/*.ts"
]
}

View File

@ -0,0 +1,49 @@
# @vitest/browser-preview
[![NPM version](https://img.shields.io/npm/v/@vitest/browser-preview?color=a1b858&label=)](https://www.npmjs.com/package/@vitest/browser-preview)
See how your tests look like in a real browser. For proper and stable browser testing, we recommend running tests in a headless browser in your CI instead. For this, you should use either:
- [@vitest/browser-playwright](https://www.npmjs.com/package/@vitest/browser-playwright) - run tests using [playwright](https://playwright.dev/)
- [@vitest/browser-webdriverio](https://www.npmjs.com/package/@vitest/browser-webdriverio) - run tests using [webdriverio](https://webdriver.io/)
## Installation
Install the package with your favorite package manager:
```sh
npm install -D @vitest/browser-preview
# or
yarn add -D @vitest/browser-preview
# or
pnpm add -D @vitest/browser-preview
```
Then specify it in the `browser.provider` field of your Vitest configuration:
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { preview } from '@vitest/browser-preview'
export default defineConfig({
test: {
browser: {
provider: preview(),
instances: [
{ name: 'chromium' },
],
},
},
})
```
Then run Vitest in the browser mode:
```sh
npx vitest --browser
```
If browser didn't open automatically, follow the link in the terminal to open the browser preview.
[GitHub](https://github.com/vitest-dev/vitest/tree/main/packages/browser-preview) | [Documentation](https://vitest.dev/guide/browser/)

1
packages/browser-preview/context.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from '@vitest/browser/context'

View File

@ -0,0 +1,49 @@
{
"name": "@vitest/browser-preview",
"type": "module",
"version": "4.0.0-beta.13",
"description": "Browser running for Vitest using your browser of choice",
"license": "MIT",
"funding": "https://opencollective.com/vitest",
"homepage": "https://vitest.dev/guide/browser",
"repository": {
"type": "git",
"url": "git+https://github.com/vitest-dev/vitest.git",
"directory": "packages/browser-preview"
},
"bugs": {
"url": "https://github.com/vitest-dev/vitest/issues"
},
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./context": {
"types": "./context.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "premove dist && pnpm rollup -c",
"dev": "rollup -c --watch --watch.include 'src/**'"
},
"peerDependencies": {
"vitest": "workspace:*"
},
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/user-event": "^14.6.1",
"@vitest/browser": "workspace:*"
},
"devDependencies": {
"vitest": "workspace:*"
}
}

View File

@ -0,0 +1,60 @@
import { createRequire } from 'node:module'
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import { defineConfig } from 'rollup'
import oxc from 'unplugin-oxc/rollup'
import { createDtsUtils } from '../../scripts/build-utils.js'
const require = createRequire(import.meta.url)
const pkg = require('./package.json')
const external = [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies || {}),
/^@?vitest(\/|$)/,
]
const dtsUtils = createDtsUtils()
const plugins = [
resolve({
preferBuiltins: true,
}),
json(),
commonjs(),
oxc({
transform: { target: 'node18' },
}),
]
export default () =>
defineConfig([
{
input: {
index: './src/index.ts',
locators: './src/locators.ts',
},
output: {
dir: 'dist',
format: 'esm',
},
external,
context: 'null',
plugins: [
...dtsUtils.isolatedDecl(),
...plugins,
],
},
{
input: dtsUtils.dtsInput('src/index.ts'),
output: {
dir: 'dist',
entryFileNames: '[name].d.ts',
format: 'esm',
},
watch: false,
external,
plugins: dtsUtils.dts(),
},
])

View File

@ -0,0 +1,5 @@
import { fileURLToPath } from 'node:url'
import { resolve } from 'pathe'
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
export const distRoot: string = resolve(pkgRoot, 'dist')

View File

@ -0,0 +1,5 @@
export {
preview,
PreviewBrowserProvider,
} from './preview'
export { defineBrowserCommand } from '@vitest/browser'

View File

@ -1,5 +1,5 @@
import { page, server, userEvent } from '@vitest/browser/context'
import {
convertElementToCssSelector,
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
@ -7,44 +7,11 @@ import {
getByTestIdSelector,
getByTextSelector,
getByTitleSelector,
} from 'ivya'
import { getElementError } from '../public-utils'
import { convertElementToCssSelector } from '../utils'
import { Locator, selectorEngine } from './index'
page.extend({
getByLabelText(text, options) {
return new PreviewLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new PreviewLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new PreviewLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new PreviewLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new PreviewLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new PreviewLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new PreviewLocator(getByTitleSelector(title, options))
},
_createLocator(selector: string) {
return new PreviewLocator(selector)
},
elementLocator(element: Element) {
return new PreviewLocator(
selectorEngine.generateSelectorSimple(element),
element,
)
},
})
Locator,
selectorEngine,
} from '@vitest/browser/locators'
import { page, server, userEvent, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
class PreviewLocator extends Locator {
constructor(protected _pwSelector: string, protected _container?: Element) {
@ -54,7 +21,7 @@ class PreviewLocator extends Locator {
override get selector() {
const selectors = this.elements().map(element => convertElementToCssSelector(element))
if (!selectors.length) {
throw getElementError(this._pwSelector, this._container || document.body)
throw utils.getElementError(this._pwSelector, this._container || document.body)
}
return selectors.join(', ')
}
@ -106,3 +73,36 @@ class PreviewLocator extends Locator {
)
}
}
page.extend({
getByLabelText(text, options) {
return new PreviewLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new PreviewLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new PreviewLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new PreviewLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new PreviewLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new PreviewLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new PreviewLocator(getByTitleSelector(title, options))
},
elementLocator(element: Element) {
return new PreviewLocator(
selectorEngine.generateSelectorSimple(element),
element,
)
},
})
__INTERNAL._createLocator = selector => new PreviewLocator(selector)

View File

@ -1,16 +1,16 @@
import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node'
import { nextTick } from 'node:process'
import { defineBrowserProvider } from '@vitest/browser'
import { resolve } from 'pathe'
import { distRoot } from './constants'
export function preview(): BrowserProviderOption {
return {
return defineBrowserProvider({
name: 'preview',
options: {},
factory(project) {
providerFactory(project) {
return new PreviewBrowserProvider(project)
},
// --browser.provider=preview
// @ts-expect-error hidden way to bypass importing preview
_cli: true,
}
})
}
export class PreviewBrowserProvider implements BrowserProvider {
@ -19,6 +19,12 @@ export class PreviewBrowserProvider implements BrowserProvider {
private project!: TestProject
private open = false
public distRoot: string = distRoot
public initScripts: string[] = [
resolve(distRoot, 'locators.js'),
]
constructor(project: TestProject) {
this.project = project
this.open = false
@ -27,7 +33,9 @@ export class PreviewBrowserProvider implements BrowserProvider {
'You\'ve enabled headless mode for "preview" provider but it doesn\'t support it. Use "playwright" or "webdriverio" instead: https://vitest.dev/guide/browser/#configuration',
)
}
project.vitest.logger.printBrowserBanner(project)
nextTick(() => {
project.vitest.logger.printBrowserBanner(project)
})
}
isOpen(): boolean {

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vite/client"],
"isolatedDeclarations": true
},
"exclude": [
"dist",
"node_modules",
"**/vite.config.ts",
"src/client/**/*.ts"
]
}

View File

@ -0,0 +1,48 @@
# @vitest/browser-webdriverio
[![NPM version](https://img.shields.io/npm/v/@vitest/browser-webdriverio?color=a1b858&label=)](https://www.npmjs.com/package/@vitest/browser-webdriverio)
Run your Vitest [browser tests](https://vitest.dev/guide/browser/) using [webdriverio](https://webdriver.io/docs/api/browser) API. Note that Vitest does not use webdriverio as a test runner, but only as a browser provider.
We recommend using this package if you are already using webdriverio in your project.
## Installation
Install the package with your favorite package manager:
```sh
npm install -D @vitest/browser-webdriverio
# or
yarn add -D @vitest/browser-webdriverio
# or
pnpm add -D @vitest/browser-webdriverio
```
Then specify it in the `browser.provider` field of your Vitest configuration:
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { webdriverio } from '@vitest/browser-webdriverio'
export default defineConfig({
test: {
browser: {
provider: webdriverio({
// ...custom webdriverio options
}),
instances: [
{ name: 'chromium' },
],
},
},
})
```
Then run Vitest in the browser mode:
```sh
npx vitest --browser
```
[GitHub](https://github.com/vitest-dev/vitest/tree/main/packages/browser-webdriverio) | [Documentation](https://vitest.dev/guide/browser/webdriverio)

View File

@ -0,0 +1 @@
export * from '@vitest/browser/context'

View File

@ -0,0 +1,55 @@
{
"name": "@vitest/browser-webdriverio",
"type": "module",
"version": "4.0.0-beta.13",
"description": "Browser running for Vitest using webdriverio",
"license": "MIT",
"funding": "https://opencollective.com/vitest",
"homepage": "https://vitest.dev/guide/browser/webdriverio",
"repository": {
"type": "git",
"url": "git+https://github.com/vitest-dev/vitest.git",
"directory": "packages/browser-webdriverio"
},
"bugs": {
"url": "https://github.com/vitest-dev/vitest/issues"
},
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./context": {
"types": "./context.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "premove dist && pnpm rollup -c",
"dev": "rollup -c --watch --watch.include 'src/**'"
},
"peerDependencies": {
"vitest": "workspace:*",
"webdriverio": "*"
},
"peerDependenciesMeta": {
"webdriverio": {
"optional": false
}
},
"dependencies": {
"@vitest/browser": "workspace:*"
},
"devDependencies": {
"@wdio/types": "^9.19.2",
"vitest": "workspace:*",
"webdriverio": "^9.19.2"
}
}

View File

@ -0,0 +1,60 @@
import { createRequire } from 'node:module'
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import { defineConfig } from 'rollup'
import oxc from 'unplugin-oxc/rollup'
import { createDtsUtils } from '../../scripts/build-utils.js'
const require = createRequire(import.meta.url)
const pkg = require('./package.json')
const external = [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies || {}),
/^@?vitest(\/|$)/,
]
const dtsUtils = createDtsUtils()
const plugins = [
resolve({
preferBuiltins: true,
}),
json(),
commonjs(),
oxc({
transform: { target: 'node18' },
}),
]
export default () =>
defineConfig([
{
input: {
index: './src/index.ts',
locators: './src/locators.ts',
},
output: {
dir: 'dist',
format: 'esm',
},
external,
context: 'null',
plugins: [
...dtsUtils.isolatedDecl(),
...plugins,
],
},
{
input: dtsUtils.dtsInput('src/index.ts'),
output: {
dir: 'dist',
entryFileNames: '[name].d.ts',
format: 'esm',
},
watch: false,
external,
plugins: dtsUtils.dts(),
},
])

View File

@ -0,0 +1,10 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const clear: UserEventCommand<UserEvent['clear']> = async (
context,
selector,
) => {
const browser = context.browser
await browser.$(selector).clearValue()
}

View File

@ -0,0 +1,44 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const click: UserEventCommand<UserEvent['click']> = async (
context,
selector,
options = {},
) => {
const browser = context.browser
await browser.$(selector).click(options as any)
}
export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (
context,
selector,
_options = {},
) => {
const browser = context.browser
await browser.$(selector).doubleClick()
}
export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async (
context,
selector,
_options = {},
) => {
const browser = context.browser
await browser
.action('pointer', { parameters: { pointerType: 'mouse' } })
// move the pointer over the button
.move({ origin: browser.$(selector) })
// simulate 3 clicks
.down()
.up()
.pause(50)
.down()
.up()
.pause(50)
.down()
.up()
.pause(50)
// run the sequence
.perform()
}

View File

@ -0,0 +1,27 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const dragAndDrop: UserEventCommand<UserEvent['dragAndDrop']> = async (
context,
source,
target,
options_,
) => {
const $source = context.browser.$(source)
const $target = context.browser.$(target)
const options = (options_ || {}) as any
const duration = options.duration ?? 10
// https://github.com/webdriverio/webdriverio/issues/8022#issuecomment-1700919670
await context.browser
.action('pointer')
.move({ duration: 0, origin: $source, x: options.sourceX ?? 0, y: options.sourceY ?? 0 })
.down({ button: 0 })
.move({ duration: 0, origin: 'pointer', x: 0, y: 0 })
.pause(duration)
.move({ duration: 0, origin: $target, x: options.targetX ?? 0, y: options.targetY ?? 0 })
.move({ duration: 0, origin: 'pointer', x: 1, y: 0 })
.move({ duration: 0, origin: 'pointer', x: -1, y: 0 })
.up({ button: 0 })
.perform()
}

View File

@ -0,0 +1,12 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const fill: UserEventCommand<UserEvent['fill']> = async (
context,
selector,
text,
_options = {},
) => {
const browser = context.browser
await browser.$(selector).setValue(text)
}

View File

@ -0,0 +1,11 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const hover: UserEventCommand<UserEvent['hover']> = async (
context,
selector,
options = {},
) => {
const browser = context.browser
await browser.$(selector).moveTo(options as any)
}

View File

@ -0,0 +1,30 @@
import { clear } from './clear'
import { click, dblClick, tripleClick } from './click'
import { dragAndDrop } from './dragAndDrop'
import { fill } from './fill'
import { hover } from './hover'
import { keyboard, keyboardCleanup } from './keyboard'
import { takeScreenshot } from './screenshot'
import { selectOptions } from './select'
import { tab } from './tab'
import { type } from './type'
import { upload } from './upload'
import { viewport } from './viewport'
export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
__vitest_fill: fill as typeof fill,
__vitest_tab: tab as typeof tab,
__vitest_keyboard: keyboard as typeof keyboard,
__vitest_selectOptions: selectOptions as typeof selectOptions,
__vitest_dragAndDrop: dragAndDrop as typeof dragAndDrop,
__vitest_hover: hover as typeof hover,
__vitest_cleanup: keyboardCleanup as typeof keyboardCleanup,
__vitest_viewport: viewport as typeof viewport,
}

View File

@ -0,0 +1,125 @@
import type { BrowserProvider } from 'vitest/node'
import type { WebdriverBrowserProvider } from '../webdriverio'
import type { UserEventCommand } from './utils'
import { parseKeyDef } from '@vitest/browser'
import { Key } from 'webdriverio'
export interface KeyboardState {
unreleased: string[]
}
export const keyboard: UserEventCommand<(
text: string,
state: KeyboardState
) => Promise<{ unreleased: string[] }>> = async (
context,
text,
state,
) => {
await context.browser.execute(focusIframe)
const pressed = new Set<string>(state.unreleased)
await keyboardImplementation(
pressed,
context.provider,
context.sessionId,
text,
async () => {
await context.browser.execute(selectAll)
},
true,
)
return {
unreleased: Array.from(pressed),
}
}
export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise<void>> = async (
context,
state,
) => {
if (!state.unreleased) {
return
}
const keyboard = context.browser.action('key')
for (const key of state.unreleased) {
keyboard.up(key)
}
await keyboard.perform()
}
export async function keyboardImplementation(
pressed: Set<string>,
provider: BrowserProvider,
_sessionId: string,
text: string,
selectAll: () => Promise<void>,
skipRelease: boolean,
): Promise<{ pressed: Set<string> }> {
const browser = (provider as WebdriverBrowserProvider).browser!
const actions = parseKeyDef(text)
let keyboard = browser.action('key')
for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) {
let key = keyDef.key!
const special = Key[key as 'Shift']
if (special) {
key = special
}
if (pressed.has(key)) {
keyboard.up(key)
pressed.delete(key)
}
if (!releasePrevious) {
if (key === 'selectall') {
await keyboard.perform()
keyboard = browser.action('key')
await selectAll()
continue
}
for (let i = 1; i <= repeat; i++) {
keyboard.down(key)
}
if (releaseSelf) {
keyboard.up(key)
}
else {
pressed.add(key)
}
}
}
// seems like webdriverio doesn't release keys automatically if skipRelease is true and all events are keyUp
const allRelease = keyboard.toJSON().actions.every(action => action.type === 'keyUp')
await keyboard.perform(allRelease ? false : skipRelease)
return {
pressed,
}
}
function focusIframe() {
if (
!document.activeElement
|| document.activeElement.ownerDocument !== document
|| document.activeElement === document.body
) {
window.focus()
}
}
function selectAll() {
const element = document.activeElement as HTMLInputElement
if (element && typeof element.select === 'function') {
element.select()
}
}

View File

@ -0,0 +1,70 @@
import type { ScreenshotOptions } from 'vitest/browser'
import type { BrowserCommandContext } from 'vitest/node'
import crypto from 'node:crypto'
import { mkdir, rm } from 'node:fs/promises'
import { normalize as platformNormalize } from 'node:path'
import { resolveScreenshotPath } from '@vitest/browser'
import { dirname, normalize, resolve } from 'pathe'
interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> {
element?: string
mask?: readonly string[]
}
/**
* Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path.
*
* **Note**: the returned `path` indicates where the screenshot *might* be found.
* It is not guaranteed to exist, especially if `options.save` is `false`.
*
* @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots.
*/
export async function takeScreenshot(
context: BrowserCommandContext,
name: string,
options: Omit<ScreenshotCommandOptions, 'base64'>,
): Promise<{ buffer: Buffer<ArrayBufferLike>; path: string }> {
if (!context.testPath) {
throw new Error(`Cannot take a screenshot without a test path`)
}
const path = resolveScreenshotPath(
context.testPath,
name,
context.project.config,
options.path,
)
// playwright does not need a screenshot path if we don't intend to save it
let savePath: string | undefined
if (options.save) {
savePath = normalize(path)
await mkdir(dirname(savePath), { recursive: true })
}
// webdriverio needs a path, so if one is not already set we create a temporary one
if (savePath === undefined) {
savePath = resolve(context.project.tmpDir, crypto.randomUUID())
await mkdir(context.project.tmpDir, { recursive: true })
}
const page = context.browser
const element = !options.element
? await page.$('body')
: await page.$(`${options.element}`)
// webdriverio expects the path to contain the extension and only works with PNG files
const savePathWithExtension = savePath.endsWith('.png') ? savePath : `${savePath}.png`
// there seems to be a bug in webdriverio, `X:/` gets appended to cwd, so we convert to `X:\`
const buffer = await element.saveScreenshot(
platformNormalize(savePathWithExtension),
)
if (!options.save) {
await rm(savePathWithExtension, { force: true })
}
return { buffer, path }
}

View File

@ -0,0 +1,25 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
export const selectOptions: UserEventCommand<UserEvent['selectOptions']> = async (
context,
selector,
userValues,
_options = {},
) => {
const values = userValues as any as [({ index: number })]
if (!values.length) {
return
}
const browser = context.browser
if (values.length === 1 && 'index' in values[0]) {
const selectElement = browser.$(selector)
await selectElement.selectByIndex(values[0].index)
}
else {
throw new Error('Provider "webdriverio" doesn\'t support selecting multiple values at once')
}
}

View File

@ -0,0 +1,11 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { Key } from 'webdriverio'
export const tab: UserEventCommand<UserEvent['tab']> = async (
context,
options = {},
) => {
const browser = context.browser
await browser.keys(options.shift === true ? [Key.Shift, Key.Tab] : [Key.Tab])
}

View File

@ -0,0 +1,38 @@
import type { UserEvent } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { keyboardImplementation } from './keyboard'
export const type: UserEventCommand<UserEvent['type']> = async (
context,
selector,
text,
options = {},
) => {
const { skipClick = false, skipAutoClose = false } = options
const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? [])
const browser = context.browser
const element = browser.$(selector)
if (!skipClick && !await element.isFocused()) {
await element.click()
}
await keyboardImplementation(
unreleased,
context.provider,
context.sessionId,
text,
() => browser.execute(() => {
const element = document.activeElement as HTMLInputElement
if (element && typeof element.select === 'function') {
element.select()
}
}),
skipAutoClose,
)
return {
unreleased: Array.from(unreleased),
}
}

View File

@ -0,0 +1,34 @@
import type { UserEventUploadOptions } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { resolve } from 'pathe'
export const upload: UserEventCommand<(element: string, files: Array<string | {
name: string
mimeType: string
base64: string
}>, options: UserEventUploadOptions) => void> = async (
context,
selector,
files,
_options,
) => {
const testPath = context.testPath
if (!testPath) {
throw new Error(`Cannot upload files outside of a test`)
}
const root = context.project.config.root
for (const file of files) {
if (typeof file !== 'string') {
throw new TypeError(`The "${context.provider.name}" provider doesn't support uploading files objects. Provide a file path instead.`)
}
}
const element = context.browser.$(selector)
for (const file of files) {
const filepath = resolve(root, file as string)
const remoteFilePath = await context.browser.uploadFile(filepath)
await element.addValue(remoteFilePath)
}
}

View File

@ -0,0 +1,11 @@
import type { Locator } from 'vitest/browser'
import type { BrowserCommand } from 'vitest/node'
export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<
ConvertUserEventParameters<Parameters<T>>
>
type ConvertElementToLocator<T> = T extends Element | Locator ? string : T
type ConvertUserEventParameters<T extends unknown[]> = {
[K in keyof T]: ConvertElementToLocator<T[K]>;
}

View File

@ -0,0 +1,9 @@
import type { WebdriverBrowserProvider } from '../webdriverio'
import type { UserEventCommand } from './utils'
export const viewport: UserEventCommand<(options: {
width: number
height: number
}) => void> = async (context, options) => {
await (context.provider as WebdriverBrowserProvider).setViewport(options)
}

View File

@ -0,0 +1,5 @@
import { fileURLToPath } from 'node:url'
import { resolve } from 'pathe'
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
export const distRoot: string = resolve(pkgRoot, 'dist')

View File

@ -0,0 +1,6 @@
export {
WebdriverBrowserProvider,
webdriverio,
type WebdriverProviderOptions,
} from './webdriverio'
export { defineBrowserCommand } from '@vitest/browser'

View File

@ -3,9 +3,9 @@ import type {
UserEventDragAndDropOptions,
UserEventHoverOptions,
UserEventSelectOptions,
} from '@vitest/browser/context'
import { page, server } from '@vitest/browser/context'
} from 'vitest/browser'
import {
convertElementToCssSelector,
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
@ -13,42 +13,12 @@ import {
getByTestIdSelector,
getByTextSelector,
getByTitleSelector,
} from 'ivya'
import { getBrowserState } from '../../utils'
import { getElementError } from '../public-utils'
import { convertElementToCssSelector, getIframeScale } from '../utils'
import { Locator, selectorEngine } from './index'
page.extend({
getByLabelText(text, options) {
return new WebdriverIOLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new WebdriverIOLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new WebdriverIOLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new WebdriverIOLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new WebdriverIOLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new WebdriverIOLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new WebdriverIOLocator(getByTitleSelector(title, options))
},
_createLocator(selector: string) {
return new WebdriverIOLocator(selector)
},
elementLocator(element: Element) {
return new WebdriverIOLocator(selectorEngine.generateSelectorSimple(element))
},
})
getIframeScale,
Locator,
selectorEngine,
} from '@vitest/browser/locators'
import { page, server, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
class WebdriverIOLocator extends Locator {
constructor(protected _pwSelector: string, protected _container?: Element) {
@ -58,7 +28,7 @@ class WebdriverIOLocator extends Locator {
override get selector(): string {
const selectors = this.elements().map(element => convertElementToCssSelector(element))
if (!selectors.length) {
throw getElementError(this._pwSelector, this._container || document.body)
throw utils.getElementError(this._pwSelector, this._container || document.body)
}
let hasShadowRoot = false
const newSelectors = selectors.map((selector) => {
@ -108,6 +78,36 @@ class WebdriverIOLocator extends Locator {
}
}
page.extend({
getByLabelText(text, options) {
return new WebdriverIOLocator(getByLabelSelector(text, options))
},
getByRole(role, options) {
return new WebdriverIOLocator(getByRoleSelector(role, options))
},
getByTestId(testId) {
return new WebdriverIOLocator(getByTestIdSelector(server.config.browser.locators.testIdAttribute, testId))
},
getByAltText(text, options) {
return new WebdriverIOLocator(getByAltTextSelector(text, options))
},
getByPlaceholder(text, options) {
return new WebdriverIOLocator(getByPlaceholderSelector(text, options))
},
getByText(text, options) {
return new WebdriverIOLocator(getByTextSelector(text, options))
},
getByTitle(title, options) {
return new WebdriverIOLocator(getByTitleSelector(title, options))
},
elementLocator(element: Element) {
return new WebdriverIOLocator(selectorEngine.generateSelectorSimple(element))
},
})
__INTERNAL._createLocator = selector => new WebdriverIOLocator(selector)
function getWebdriverioSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement | Locator | Locator[]) {
const options = [...element.querySelectorAll('option')] as HTMLOptionElement[]
@ -149,12 +149,11 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[]
return [{ index: labelIndex }]
}
function processClickOptions(options_?: UserEventClickOptions) {
function processClickOptions(options?: UserEventClickOptions) {
// only ui scales the iframe, so we need to adjust the position
if (!options_ || !getBrowserState().config.browser.ui) {
return options_
if (!options || !server.config.browser.ui) {
return options
}
const options = options_ as import('webdriverio').ClickOptions
if (options.x != null || options.y != null) {
const cache = {}
if (options.x != null) {
@ -164,15 +163,14 @@ function processClickOptions(options_?: UserEventClickOptions) {
options.y = scaleCoordinate(options.y, cache)
}
}
return options_
return options
}
function processHoverOptions(options_?: UserEventHoverOptions) {
function processHoverOptions(options?: UserEventHoverOptions) {
// only ui scales the iframe, so we need to adjust the position
if (!options_ || !getBrowserState().config.browser.ui) {
return options_
if (!options || !server.config.browser.ui) {
return options
}
const options = options_ as import('webdriverio').MoveToOptions
const cache = {}
if (options.xOffset != null) {
options.xOffset = scaleCoordinate(options.xOffset, cache)
@ -180,21 +178,15 @@ function processHoverOptions(options_?: UserEventHoverOptions) {
if (options.yOffset != null) {
options.yOffset = scaleCoordinate(options.yOffset, cache)
}
return options_
return options
}
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
function processDragAndDropOptions(options?: UserEventDragAndDropOptions) {
// only ui scales the iframe, so we need to adjust the position
if (!options_ || !getBrowserState().config.browser.ui) {
return options_
if (!options || !server.config.browser.ui) {
return options
}
const cache = {}
const options = options_ as import('webdriverio').DragAndDropOptions & {
targetX?: number
targetY?: number
sourceX?: number
sourceY?: number
}
if (options.sourceX != null) {
options.sourceX = scaleCoordinate(options.sourceX, cache)
}
@ -207,7 +199,7 @@ function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
if (options.targetY != null) {
options.targetY = scaleCoordinate(options.targetY, cache)
}
return options_
return options
}
function scaleCoordinate(coordinate: number, cache: any) {

View File

@ -1,39 +1,41 @@
import type { Capabilities } from '@wdio/types'
import type {
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
} from '@vitest/browser/context'
import type { Capabilities } from '@wdio/types'
} from 'vitest/browser'
import type {
BrowserCommand,
BrowserProvider,
BrowserProviderOption,
CDPSession,
TestProject,
} from 'vitest/node'
import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio'
import type { ClickOptions, DragAndDropOptions, MoveToOptions, remote } from 'webdriverio'
import { defineBrowserProvider } from '@vitest/browser'
import { resolve } from 'pathe'
import { createDebugger } from 'vitest/node'
import commands from './commands'
import { distRoot } from './constants'
const debug = createDebugger('vitest:browser:wdio')
const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const
type WebdriverBrowser = (typeof webdriverBrowsers)[number]
interface WebdriverProviderOptions extends Partial<
export interface WebdriverProviderOptions extends Partial<
Parameters<typeof remote>[0]
> {}
export function webdriverio(options: WebdriverProviderOptions = {}): BrowserProviderOption<WebdriverProviderOptions> {
return {
return defineBrowserProvider({
name: 'webdriverio',
supportedBrowser: webdriverBrowsers,
options,
factory(project) {
providerFactory(project) {
return new WebdriverBrowserProvider(project, options)
},
// --browser.provider=webdriverio
// @ts-expect-error hidden way to bypass importing webdriverio
_cli: true,
}
})
}
export class WebdriverBrowserProvider implements BrowserProvider {
@ -51,6 +53,10 @@ export class WebdriverBrowserProvider implements BrowserProvider {
private iframeSwitched = false
private topLevelContext: string | undefined
public initScripts: string[] = [
resolve(distRoot, 'locators.js'),
]
getSupportedBrowsers(): readonly string[] {
return webdriverBrowsers
}
@ -69,6 +75,10 @@ export class WebdriverBrowserProvider implements BrowserProvider {
this.project = project
this.browserName = project.config.browser.name as WebdriverBrowser
this.options = options
for (const [name, command] of Object.entries(commands)) {
project.browser!.registerCommand(name as any, command as BrowserCommand)
}
}
isIframeSwitched(): boolean {
@ -271,20 +281,27 @@ export class WebdriverBrowserProvider implements BrowserProvider {
}
}
declare module 'vitest/node' {
export interface UserEventClickOptions extends ClickOptions {}
declare module 'vitest/browser' {
export interface UserEventClickOptions extends Partial<ClickOptions> {}
export interface UserEventHoverOptions extends MoveToOptions {}
export interface UserEventDragOptions extends DragAndDropOptions {
export interface UserEventDragAndDropOptions extends DragAndDropOptions {
sourceX?: number
sourceY?: number
targetX?: number
targetY?: number
}
}
declare module 'vitest/node' {
export interface BrowserCommandContext {
browser: WebdriverIO.Browser
}
export interface _BrowserNames {
webdriverio: WebdriverBrowser
}
export interface ToMatchScreenshotOptions
extends Omit<
ScreenshotMatcherOptions,

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vite/client"],
"isolatedDeclarations": true
},
"exclude": [
"dist",
"node_modules",
"**/vite.config.ts",
"src/client/**/*.ts"
]
}

View File

@ -1,4 +1,5 @@
import { SerializedConfig } from 'vitest'
import { StringifyOptions } from 'vitest/internal/browser'
import { ARIARole } from './aria-role.js'
import {} from './matchers.js'
@ -186,7 +187,7 @@ export interface UserEvent {
* state of keyboard to press and release buttons correctly.
*
* **Note:** Unlike `@testing-library/user-event`, the default `userEvent` instance
* from `@vitest/browser/context` is created once, not every time its methods are called!
* from `vitest/browser` is created once, not every time its methods are called!
* @see {@link https://vitest.dev/guide/browser/interactivity-api.html#userevent-setup}
*/
setup: () => UserEvent
@ -361,6 +362,10 @@ export interface LocatorOptions {
* regular expression. Note that exact match still trims whitespace.
*/
exact?: boolean
hasText?: string | RegExp
hasNotText?: string | RegExp
has?: Locator
hasNot?: Locator
}
export interface LocatorByRoleOptions extends LocatorOptions {
@ -660,13 +665,6 @@ export const server: {
config: SerializedConfig
}
export interface LocatorOptions {
hasText?: string | RegExp
hasNotText?: string | RegExp
has?: Locator
hasNot?: Locator
}
/**
* Handler for user interactions. The support is provided by the browser provider (`playwright` or `webdriverio`).
* If used with `preview` provider, fallbacks to simulated events via `@testing-library/user-event`.
@ -732,11 +730,54 @@ export interface BrowserPage extends LocatorSelectors {
export interface BrowserLocators {
createElementLocators(element: Element): LocatorSelectors
// TODO: enhance docs
/**
* Extends `page.*` and `locator.*` interfaces.
* @see {@link}
*
* @example
* ```ts
* import { locators } from 'vitest/browser'
*
* declare module 'vitest/browser' {
* interface LocatorSelectors {
* getByCSS(css: string): Locator
* }
* }
*
* locators.extend({
* getByCSS(css: string) {
* return `css=${css}`
* }
* })
* ```
*/
extend(methods: {
[K in keyof LocatorSelectors]?: (this: BrowserPage | Locator, ...args: Parameters<LocatorSelectors[K]>) => ReturnType<LocatorSelectors[K]> | string
[K in keyof LocatorSelectors]?: (
this: BrowserPage | Locator,
...args: Parameters<LocatorSelectors[K]>
) => ReturnType<LocatorSelectors[K]> | string
}): void
}
export type PrettyDOMOptions = Omit<StringifyOptions, 'maxLength'>
export const utils: {
getElementLocatorSelectors(element: Element): LocatorSelectors
debug(
el?: Element | Locator | null | (Element | Locator)[],
maxLength?: number,
options?: PrettyDOMOptions,
): void
prettyDOM(
dom?: Element | Locator | undefined | null,
maxLength?: number,
prettyFormatOptions?: PrettyDOMOptions,
): string
getElementError(selector: string, container?: Element): Error
}
export const locators: BrowserLocators
export const page: BrowserPage

View File

@ -1,4 +1,4 @@
// Vitest resolves "@vitest/browser/context" as a virtual module instead
// Vitest resolves "vitest/browser" as a virtual module instead
// fake exports for static analysis
export const page = null
@ -7,12 +7,13 @@ export const userEvent = null
export const cdp = null
export const commands = null
export const locators = null
export const utils = null
const pool = globalThis.__vitest_worker__?.ctx?.pool
throw new Error(
// eslint-disable-next-line prefer-template
'@vitest/browser/context can be imported only inside the Browser Mode. '
'vitest/browser can be imported only inside the Browser Mode. '
+ (pool
? `Your test is running in ${pool} pool. Make sure your regular tests are excluded from the "test.include" glob pattern.`
: 'Instead, it was imported outside of Vitest.'),

View File

@ -23,7 +23,7 @@ export interface TestingLibraryMatchers<E, R> {
* This matcher calculates the intersection ratio between the element and the viewport, similar to the
* IntersectionObserver API.
*
* The element must be in the document and have visible dimensions. Elements with display: none or
* The element must be in the document and have visible dimensions. Elements with display: none or
* visibility: hidden are considered not in viewport.
* @example
* <div
@ -34,7 +34,7 @@ export interface TestingLibraryMatchers<E, R> {
* </div>
*
* <div
* data-testid="hidden-element"
* data-testid="hidden-element"
* style="position: fixed; top: -100px; left: 10px; width: 50px; height: 50px;"
* >
* Hidden Element
@ -49,13 +49,13 @@ export interface TestingLibraryMatchers<E, R> {
*
* // Check if any part of element is in viewport
* await expect.element(page.getByTestId('visible-element')).toBeInViewport()
*
*
* // Check if element is outside viewport
* await expect.element(page.getByTestId('hidden-element')).not.toBeInViewport()
*
*
* // Check if at least 50% of element is visible
* await expect.element(page.getByTestId('large-element')).toBeInViewport({ ratio: 0.5 })
*
*
* // Check if element is completely visible
* await expect.element(page.getByTestId('visible-element')).toBeInViewport({ ratio: 1 })
* @see https://vitest.dev/guide/browser/assertion-api#tobeinviewport

View File

@ -1,4 +1,4 @@
import type { Locator } from '@vitest/browser/context'
import type { Locator } from './context.js'
import type { TestingLibraryMatchers } from './jest-dom.js'
import type { Assertion, ExpectPollOptions } from 'vitest'

View File

@ -20,10 +20,6 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./providers/*": {
"types": "./dist/providers/*.d.ts",
"default": "./dist/providers/*.js"
},
"./context": {
"types": "./context.d.ts",
"default": "./context.js"
@ -35,15 +31,15 @@
"types": "./matchers.d.ts",
"default": "./dummy.js"
},
"./locator": {
"types": "./dist/locators/index.d.ts",
"default": "./dist/locators/index.js"
"./locators": {
"types": "./dist/locators.d.ts",
"default": "./dist/locators.js"
},
"./utils": {
"types": "./utils.d.ts",
"default": "./dist/utils.js"
},
"./*": "./*"
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
@ -66,24 +62,9 @@
"dev": "premove dist && pnpm run --stream '/^dev:/'"
},
"peerDependencies": {
"playwright": "*",
"vitest": "workspace:*",
"webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
},
"safaridriver": {
"optional": true
},
"webdriverio": {
"optional": true
}
"vitest": "workspace:*"
},
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/user-event": "^14.6.1",
"@vitest/mocker": "workspace:*",
"@vitest/utils": "workspace:*",
"magic-string": "catalog:",
@ -94,18 +75,15 @@
"ws": "catalog:"
},
"devDependencies": {
"@testing-library/user-event": "^14.6.1",
"@types/pngjs": "^6.0.5",
"@types/ws": "catalog:",
"@vitest/runner": "workspace:*",
"@wdio/types": "^9.19.2",
"birpc": "catalog:",
"flatted": "catalog:",
"ivya": "^1.7.0",
"mime": "^4.1.0",
"pathe": "catalog:",
"playwright": "^1.55.0",
"playwright-core": "^1.55.0",
"vitest": "workspace:*",
"webdriverio": "^9.19.2"
"vitest": "workspace:*"
}
}

View File

@ -1,7 +0,0 @@
import type { BrowserProviderModule } from 'vitest/node'
declare const webdriverio: BrowserProviderModule
declare const playwright: BrowserProviderModule
declare const preview: BrowserProviderModule
export { webdriverio, playwright, preview }

View File

@ -14,10 +14,13 @@ const external = [
...Object.keys(pkg.peerDependencies || {}),
/^@?vitest(\/|$)/,
'@vitest/browser/utils',
'@vitest/browser/context',
'@vitest/browser/client',
'vitest/browser',
'worker_threads',
'node:worker_threads',
'vite',
'playwright-core/types/protocol',
'vitest/internal/browser',
]
const dtsUtils = createDtsUtils()
@ -39,10 +42,7 @@ const plugins = [
]
const input = {
'index': './src/node/index.ts',
'providers/playwright': './src/node/providers/playwright.ts',
'providers/webdriverio': './src/node/providers/webdriverio.ts',
'providers/preview': './src/node/providers/preview.ts',
index: './src/node/index.ts',
}
export default () =>
@ -75,12 +75,8 @@ export default () =>
},
{
input: {
'locators/playwright': './src/client/tester/locators/playwright.ts',
'locators/webdriverio': './src/client/tester/locators/webdriverio.ts',
'locators/preview': './src/client/tester/locators/preview.ts',
'locators/index': './src/client/tester/locators/index.ts',
'locators': './src/client/tester/locators/index.ts',
'expect-element': './src/client/tester/expect-element.ts',
'utils': './src/client/tester/public-utils.ts',
},
output: {
dir: 'dist',
@ -102,7 +98,7 @@ export default () =>
file: 'dist/context.js',
format: 'esm',
},
external: ['@vitest/browser/utils'],
external: ['vitest/internal/browser'],
plugins: [
oxc({
transform: { target: 'node18' },
@ -150,7 +146,7 @@ export default () =>
},
{
input: dtsUtilsClient.dtsInput({
'locators/index': './src/client/tester/locators/index.ts',
locators: './src/client/tester/locators/index.ts',
}),
output: {
dir: 'dist',
@ -161,17 +157,4 @@ export default () =>
external,
plugins: dtsUtilsClient.dts(),
},
// {
// input: './src/client/tester/jest-dom.ts',
// output: {
// file: './jest-dom.d.ts',
// format: 'esm',
// },
// external: [],
// plugins: [
// dts({
// respectExternal: true,
// }),
// ],
// },
])

View File

@ -143,7 +143,7 @@ function createClient() {
`Cannot connect to the server in ${connectTimeout / 1000} seconds`,
),
)
}, connectTimeout)?.unref?.()
}, connectTimeout)
if (ctx.ws.OPEN === ctx.ws.readyState) {
resolve()
}

View File

@ -29,6 +29,8 @@ export class IframeOrchestrator {
}
public async createTesters(options: BrowserTesterOptions): Promise<void> {
const startTime = performance.now()
this.cancelled = false
const config = getConfig()
@ -46,7 +48,7 @@ export class IframeOrchestrator {
}
if (config.browser.isolate === false) {
await this.runNonIsolatedTests(container, options)
await this.runNonIsolatedTests(container, options, startTime)
return
}
@ -65,6 +67,7 @@ export class IframeOrchestrator {
container,
file,
options,
startTime,
)
}
}
@ -95,7 +98,7 @@ export class IframeOrchestrator {
this.recreateNonIsolatedIframe = true
}
private async runNonIsolatedTests(container: HTMLDivElement, options: BrowserTesterOptions) {
private async runNonIsolatedTests(container: HTMLDivElement, options: BrowserTesterOptions, startTime: number) {
if (this.recreateNonIsolatedIframe) {
// recreate a new non-isolated iframe during watcher reruns
// because we called "cleanup" in the previous run
@ -108,7 +111,7 @@ export class IframeOrchestrator {
if (!this.iframes.has(ID_ALL)) {
debug('preparing non-isolated iframe')
await this.prepareIframe(container, ID_ALL, options.startTime)
await this.prepareIframe(container, ID_ALL, startTime)
}
const config = getConfig()
@ -133,6 +136,7 @@ export class IframeOrchestrator {
container: HTMLDivElement,
file: string,
options: BrowserTesterOptions,
startTime: number,
) {
const config = getConfig()
const { width, height } = config.browser.viewport
@ -142,7 +146,7 @@ export class IframeOrchestrator {
this.iframes.delete(file)
}
const iframe = await this.prepareIframe(container, file, options.startTime)
const iframe = await this.prepareIframe(container, file, startTime)
await setIframeViewport(iframe, width, height)
// running tests after the "prepare" event
await sendEventToIframe({

View File

@ -2,19 +2,21 @@ import type {
Options as TestingLibraryOptions,
UserEvent as TestingLibraryUserEvent,
} from '@testing-library/user-event'
import type { RunnerTask } from 'vitest'
import type {
BrowserLocators,
BrowserPage,
Locator,
LocatorSelectors,
UserEvent,
} from '@vitest/browser/context'
import type { RunnerTask } from 'vitest'
} from 'vitest/browser'
import type { StringifyOptions } from 'vitest/internal/browser'
import type { IframeViewportEvent } from '../client'
import type { BrowserRunnerState } from '../utils'
import type { Locator as LocatorAPI } from './locators/index'
import { getElementLocatorSelectors } from '@vitest/browser/utils'
import { __INTERNAL, stringify } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
import { convertToSelector, processTimeoutOptions } from './utils'
import { convertToSelector, processTimeoutOptions } from './tester-utils'
// this file should not import anything directly, only types and utils
@ -38,7 +40,7 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
// https://playwright.dev/docs/api/class-keyboard
// https://webdriver.io/docs/api/browser/keys/
const modifier = provider === `playwright`
const modifier = provider === 'playwright'
? 'ControlOrMeta'
: provider === 'webdriverio'
? 'Ctrl'
@ -340,9 +342,6 @@ export const page: BrowserPage = {
frameLocator() {
throw new Error(`Method "frameLocator" is not supported by the "${provider}" provider.`)
},
_createLocator() {
throw new Error(`Method "_createLocator" is not supported by the "${provider}" provider.`)
},
extend(methods) {
for (const key in methods) {
(page as any)[key] = (methods as any)[key].bind(page)
@ -365,9 +364,9 @@ function getTaskFullName(task: RunnerTask): string {
export const locators: BrowserLocators = {
createElementLocators: getElementLocatorSelectors,
extend(methods) {
const Locator = page._createLocator('css=body').constructor as typeof LocatorAPI
const Locator = __INTERNAL._createLocator('css=body').constructor as typeof LocatorAPI
for (const method in methods) {
locators._extendedMethods.add(method)
__INTERNAL._extendedMethods.add(method)
const cb = (methods as any)[method] as (...args: any[]) => string | Locator
// @ts-expect-error types are hard to make work
Locator.prototype[method] = function (...args: any[]) {
@ -380,23 +379,90 @@ export const locators: BrowserLocators = {
page[method as 'getByRole'] = function (...args: any[]) {
const selectorOrLocator = cb.call(this, ...args)
if (typeof selectorOrLocator === 'string') {
return page._createLocator(selectorOrLocator)
return __INTERNAL._createLocator(selectorOrLocator)
}
return selectorOrLocator
}
}
},
_extendedMethods: new Set<string>(),
}
declare module '@vitest/browser/context' {
interface BrowserPage {
/** @internal */
_createLocator: (selector: string) => Locator
}
interface BrowserLocators {
/** @internal */
_extendedMethods: Set<string>
function getElementLocatorSelectors(element: Element): LocatorSelectors {
const locator = page.elementLocator(element)
return {
getByAltText: (altText, options) => locator.getByAltText(altText, options),
getByLabelText: (labelText, options) => locator.getByLabelText(labelText, options),
getByPlaceholder: (placeholderText, options) => locator.getByPlaceholder(placeholderText, options),
getByRole: (role, options) => locator.getByRole(role, options),
getByTestId: testId => locator.getByTestId(testId),
getByText: (text, options) => locator.getByText(text, options),
getByTitle: (title, options) => locator.getByTitle(title, options),
...Array.from(__INTERNAL._extendedMethods).reduce((methods, method) => {
methods[method] = (...args: any[]) => (locator as any)[method](...args)
return methods
}, {} as any),
}
}
type PrettyDOMOptions = Omit<StringifyOptions, 'maxLength'>
function debug(
el?: Element | Locator | null | (Element | Locator)[],
maxLength?: number,
options?: PrettyDOMOptions,
): void {
if (Array.isArray(el)) {
// eslint-disable-next-line no-console
el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
}
else {
// eslint-disable-next-line no-console
console.log(prettyDOM(el, maxLength, options))
}
}
function prettyDOM(
dom?: Element | Locator | undefined | null,
maxLength: number = Number(import.meta.env.DEBUG_PRINT_LIMIT ?? 7000),
prettyFormatOptions: PrettyDOMOptions = {},
): string {
if (maxLength === 0) {
return ''
}
if (!dom) {
dom = document.body
}
if ('element' in dom && 'all' in dom) {
dom = dom.element()
}
const type = typeof dom
if (type !== 'object' || !dom.outerHTML) {
const typeName = type === 'object' ? dom.constructor.name : type
throw new TypeError(`Expecting a valid DOM element, but got ${typeName}.`)
}
const pretty = stringify(dom, Number.POSITIVE_INFINITY, {
maxLength,
highlight: true,
...prettyFormatOptions,
})
return dom.outerHTML.length > maxLength
? `${pretty.slice(0, maxLength)}...`
: pretty
}
function getElementError(selector: string, container: Element): Error {
const error = new Error(`Cannot find element with locator: ${__INTERNAL._asLocator('javascript', selector)}\n\n${prettyDOM(container)}`)
error.name = 'VitestBrowserElementError'
return error
}
export const utils = {
getElementError,
prettyDOM,
debug,
getElementLocatorSelectors,
}

View File

@ -1,9 +1,9 @@
import type { Locator } from '@vitest/browser/context'
import type { ExpectPollOptions, PromisifyDomAssertion } from 'vitest'
import type { Locator } from 'vitest/browser'
import { chai, expect } from 'vitest'
import { getType } from 'vitest/internal/browser'
import { matchers } from './expect'
import { processTimeoutOptions } from './utils'
import { processTimeoutOptions } from './tester-utils'
const kLocator = Symbol.for('$$vitest:locator')

View File

@ -15,8 +15,8 @@
import type { ExpectationResult, MatcherState } from '@vitest/expect'
import type { Locator } from '../locators'
import { server } from '@vitest/browser/context'
import { beginAriaCaches, endAriaCaches, isElementVisible as ivyaIsVisible } from 'ivya/utils'
import { server } from 'vitest/browser'
import { getElementFromUserInput } from './utils'
export default function toBeVisible(

View File

@ -1,6 +1,6 @@
import type { ExpectationResult, MatcherState } from '@vitest/expect'
import type { Locator } from '../locators'
import { server } from '@vitest/browser/context'
import { server } from 'vitest/browser'
import { getElementFromUserInput } from './utils'
const browser = server.config.browser.name

View File

@ -3,7 +3,7 @@ import type { ScreenshotMatcherOptions } from '../../../../context'
import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types'
import type { Locator } from '../locators'
import { getBrowserState, getWorkerState } from '../../utils'
import { convertToSelector } from '../utils'
import { convertToSelector } from '../tester-utils'
const counters = new Map<string, { current: number }>([])

View File

@ -1,3 +1,4 @@
import type { ParsedSelector } from 'ivya'
import type {
LocatorByRoleOptions,
LocatorOptions,
@ -9,10 +10,9 @@ import type {
UserEventHoverOptions,
UserEventSelectOptions,
UserEventUploadOptions,
} from '@vitest/browser/context'
import type { ParsedSelector } from 'ivya'
import { page, server } from '@vitest/browser/context'
} from 'vitest/browser'
import {
asLocator,
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
@ -21,11 +21,24 @@ import {
getByTextSelector,
getByTitleSelector,
Ivya,
} from 'ivya'
import { page, server, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState } from '../../utils'
import { getElementError } from '../public-utils'
import { escapeForTextSelector } from '../utils'
import { escapeForTextSelector, isLocator } from '../tester-utils'
export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils'
export {
getByAltTextSelector,
getByLabelSelector,
getByPlaceholderSelector,
getByRoleSelector,
getByTestIdSelector,
getByTextSelector,
getByTitleSelector,
} from 'ivya'
__INTERNAL._asLocator = asLocator
// we prefer using playwright locators because they are more powerful and support Shadow DOM
export const selectorEngine: Ivya = Ivya.create({
@ -125,7 +138,7 @@ export abstract class Locator {
): Promise<void> {
const values = (Array.isArray(value) ? value : [value]).map((v) => {
if (typeof v !== 'string') {
const selector = 'element' in v ? v.selector : selectorEngine.generateSelectorSimple(v)
const selector = isLocator(v) ? v.selector : selectorEngine.generateSelectorSimple(v)
return { element: selector }
}
return v
@ -223,7 +236,7 @@ export abstract class Locator {
public element(): HTMLElement | SVGElement {
const element = this.query()
if (!element) {
throw getElementError(this._pwSelector || this.selector, this._container || document.body)
throw utils.getElementError(this._pwSelector || this.selector, this._container || document.body)
}
return element
}

View File

@ -1,78 +0,0 @@
import type { Locator, LocatorSelectors } from '@vitest/browser/context'
import type { StringifyOptions } from 'vitest/internal/browser'
import { locators, page } from '@vitest/browser/context'
import { asLocator } from 'ivya'
import { stringify } from 'vitest/internal/browser'
export function getElementLocatorSelectors(element: Element): LocatorSelectors {
const locator = page.elementLocator(element)
return {
getByAltText: (altText, options) => locator.getByAltText(altText, options),
getByLabelText: (labelText, options) => locator.getByLabelText(labelText, options),
getByPlaceholder: (placeholderText, options) => locator.getByPlaceholder(placeholderText, options),
getByRole: (role, options) => locator.getByRole(role, options),
getByTestId: testId => locator.getByTestId(testId),
getByText: (text, options) => locator.getByText(text, options),
getByTitle: (title, options) => locator.getByTitle(title, options),
...Array.from(locators._extendedMethods).reduce((methods, method) => {
methods[method] = (...args: any[]) => (locator as any)[method](...args)
return methods
}, {} as any),
}
}
type PrettyDOMOptions = Omit<StringifyOptions, 'maxLength'>
export function debug(
el?: Element | Locator | null | (Element | Locator)[],
maxLength?: number,
options?: PrettyDOMOptions,
): void {
if (Array.isArray(el)) {
// eslint-disable-next-line no-console
el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
}
else {
// eslint-disable-next-line no-console
console.log(prettyDOM(el, maxLength, options))
}
}
export function prettyDOM(
dom?: Element | Locator | undefined | null,
maxLength: number = Number(import.meta.env.DEBUG_PRINT_LIMIT ?? 7000),
prettyFormatOptions: PrettyDOMOptions = {},
): string {
if (maxLength === 0) {
return ''
}
if (!dom) {
dom = document.body
}
if ('element' in dom && 'all' in dom) {
dom = dom.element()
}
const type = typeof dom
if (type !== 'object' || !dom.outerHTML) {
const typeName = type === 'object' ? dom.constructor.name : type
throw new TypeError(`Expecting a valid DOM element, but got ${typeName}.`)
}
const pretty = stringify(dom, Number.POSITIVE_INFINITY, {
maxLength,
highlight: true,
...prettyFormatOptions,
})
return dom.outerHTML.length > maxLength
? `${pretty.slice(0, maxLength)}...`
: pretty
}
export function getElementError(selector: string, container: Element): Error {
const error = new Error(`Cannot find element with locator: ${asLocator('javascript', selector)}\n\n${prettyDOM(container)}`)
error.name = 'VitestBrowserElementError'
return error
}

View File

@ -11,10 +11,10 @@ import type {
} from '@vitest/runner'
import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest'
import type { VitestBrowserClientMocker } from './mocker'
import type { CommandsManager } from './utils'
import type { CommandsManager } from './tester-utils'
import { globalChannel, onCancel } from '@vitest/browser/client'
import { page, userEvent } from '@vitest/browser/context'
import { getTestName } from '@vitest/runner/utils'
import { page, userEvent } from 'vitest/browser'
import {
DecodedMap,
getOriginalPosition,

View File

@ -1,4 +1,4 @@
import type { Locator } from '@vitest/browser/context'
import type { Locator } from 'vitest/browser'
import type { BrowserRPC } from '../client'
import { getBrowserState, getWorkerState } from '../utils'
@ -206,8 +206,14 @@ export function convertToSelector(elementOrLocator: Element | Locator): string {
if (elementOrLocator instanceof Element) {
return convertElementToCssSelector(elementOrLocator)
}
if ('selector' in elementOrLocator) {
return (elementOrLocator as any).selector
if (isLocator(elementOrLocator)) {
return elementOrLocator.selector
}
throw new Error('Expected element or locator to be an instance of Element or Locator.')
}
const kLocator = Symbol.for('$$vitest:locator')
export function isLocator(element: unknown): element is Locator {
return (!!element && typeof element === 'object' && kLocator in element)
}

View File

@ -1,7 +1,7 @@
import type { BrowserRPC, IframeChannelEvent } from '@vitest/browser/client'
import { channel, client, onCancel } from '@vitest/browser/client'
import { page, server, userEvent } from '@vitest/browser/context'
import { parse } from 'flatted'
import { page, server, userEvent } from 'vitest/browser'
import {
collectTests,
setupCommonEnv,
@ -17,7 +17,7 @@ import { VitestBrowserClientMocker } from './mocker'
import { createModuleMockerInterceptor } from './mocker-interceptor'
import { createSafeRpc } from './rpc'
import { browserHashMap, initiateRunner } from './runner'
import { CommandsManager } from './utils'
import { CommandsManager } from './tester-utils'
const debugVar = getConfig().env.VITEST_BROWSER_DEBUG
const debug = debugVar && debugVar !== 'false'

View File

@ -1,7 +1,7 @@
import type { VitestRunner } from '@vitest/runner'
import type { EvaluatedModules, SerializedConfig, WorkerGlobalState } from 'vitest'
import type { IframeOrchestrator } from './orchestrator'
import type { CommandsManager } from './tester/utils'
import type { CommandsManager } from './tester/tester-utils'
export async function importId(id: string): Promise<any> {
const name = `/@id/${id}`.replace(/\\/g, '/')

View File

@ -35,7 +35,7 @@ export default vite.defineConfig({
/^vitest\//,
'vitest',
/^msw/,
'@vitest/browser/context',
'vitest/browser',
'@vitest/browser/client',
],
},

View File

@ -1,23 +0,0 @@
import type { UserEvent } from '../../../context'
import type { UserEventCommand } from './utils'
import { PlaywrightBrowserProvider } from '../providers/playwright'
import { WebdriverBrowserProvider } from '../providers/webdriverio'
export const clear: UserEventCommand<UserEvent['clear']> = async (
context,
selector,
) => {
if (context.provider instanceof PlaywrightBrowserProvider) {
const { iframe } = context
const element = iframe.locator(selector)
await element.clear()
}
else if (context.provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const element = await browser.$(selector)
await element.clearValue()
}
else {
throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`)
}
}

Some files were not shown because too many files have changed in this diff Show More