From 45e49cd89be485a11aa7a15cb76dc7eeba045406 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 26 Sep 2025 18:01:03 +0200 Subject: [PATCH 01/43] feat: introduce separate packages for browser mode providers --- eslint.config.js | 2 +- packages/browser-playwright/package.json | 54 ++ packages/browser-playwright/rollup.config.js | 62 ++ packages/browser-playwright/src/index.ts | 5 + packages/browser-playwright/src/provider.ts | 591 ++++++++++++++++++ packages/browser-playwright/tsconfig.json | 13 + packages/browser/context.d.ts | 22 + .../browser/src/node/providers/playwright.ts | 2 +- .../browser/src/node/providers/preview.ts | 2 +- .../browser/src/node/providers/webdriverio.ts | 2 +- packages/browser/src/node/utils.ts | 10 +- packages/vitest/src/node/project.ts | 27 +- packages/vitest/src/node/types/browser.ts | 11 +- pnpm-lock.yaml | 22 + 14 files changed, 792 insertions(+), 33 deletions(-) create mode 100644 packages/browser-playwright/package.json create mode 100644 packages/browser-playwright/rollup.config.js create mode 100644 packages/browser-playwright/src/index.ts create mode 100644 packages/browser-playwright/src/provider.ts create mode 100644 packages/browser-playwright/tsconfig.json diff --git a/eslint.config.js b/eslint.config.js index 459c82188fd0..3ca7083d0abe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/packages/browser-playwright/package.json b/packages/browser-playwright/package.json new file mode 100644 index 000000000000..2614d2c3415a --- /dev/null +++ b/packages/browser-playwright/package.json @@ -0,0 +1,54 @@ +{ + "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" + }, + "./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": { + "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:*" + } +} diff --git a/packages/browser-playwright/rollup.config.js b/packages/browser-playwright/rollup.config.js new file mode 100644 index 000000000000..4a5ab941916b --- /dev/null +++ b/packages/browser-playwright/rollup.config.js @@ -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(\/|$)/, + '@vitest/browser/utils', + 'worker_threads', + 'node:worker_threads', + 'vite', + 'playwright-core/types/protocol', +] + +const dtsUtils = createDtsUtils() + +const plugins = [ + resolve({ + preferBuiltins: true, + }), + json(), + commonjs(), + oxc({ + transform: { target: 'node18' }, + }), +] + +export default () => + defineConfig([ + { + input: './src/index.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(), + }, + ]) diff --git a/packages/browser-playwright/src/index.ts b/packages/browser-playwright/src/index.ts new file mode 100644 index 000000000000..385b72c1693d --- /dev/null +++ b/packages/browser-playwright/src/index.ts @@ -0,0 +1,5 @@ +export { + playwright, + PlaywrightBrowserProvider, + type PlaywrightProviderOptions, +} from './provider' diff --git a/packages/browser-playwright/src/provider.ts b/packages/browser-playwright/src/provider.ts new file mode 100644 index 000000000000..49aed2d3d345 --- /dev/null +++ b/packages/browser-playwright/src/provider.ts @@ -0,0 +1,591 @@ +/* eslint-disable ts/method-signature-style */ + +import type { + ScreenshotComparatorRegistry, + ScreenshotMatcherOptions, +} from '@vitest/browser/context' +import type { MockedModule } from '@vitest/mocker' +import type { + Browser, + BrowserContext, + BrowserContextOptions, + ConnectOptions, + Frame, + FrameLocator, + LaunchOptions, + Page, +} from 'playwright' +import type { Protocol } from 'playwright-core/types/protocol' +import type { SourceMap } from 'rollup' +import type { ResolvedConfig } from 'vite' +import type { + BrowserModuleMocker, + BrowserProvider, + BrowserProviderOption, + CDPSession, + TestProject, +} from 'vitest/node' +import { createBrowserServer } from '@vitest/browser' +import { createManualModuleSource } from '@vitest/mocker/node' +import c from 'tinyrainbow' +import { createDebugger, isCSSRequest } from 'vitest/node' + +const debug = createDebugger('vitest:browser:playwright') + +const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const +type PlaywrightBrowser = (typeof playwrightBrowsers)[number] + +export interface PlaywrightProviderOptions { + /** + * The options passed down to [`playwright.connect`](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) method. + * @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-launch} + */ + launchOptions?: Omit< + LaunchOptions, + 'tracesDir' + > + /** + * The options passed down to [`playwright.connect`](https://playwright.dev/docs/api/class-browsertype#browser-type-connect) method. + * + * This is used only if you connect remotely to the playwright instance via a WebSocket connection. + * @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-connect} + */ + connectOptions?: ConnectOptions & { + wsEndpoint: string + } + /** + * The options passed down to [`browser.newContext`](https://playwright.dev/docs/api/class-browser#browser-new-context) method. + * @see {@link https://playwright.dev/docs/api/class-browser#browser-new-context} + */ + contextOptions?: Omit< + BrowserContextOptions, + 'ignoreHTTPSErrors' | 'serviceWorkers' + > + /** + * The maximum time in milliseconds to wait for `userEvent` action to complete. + * @default 0 (no timeout) + */ + actionTimeout?: number +} + +export function playwright(options: PlaywrightProviderOptions = {}): BrowserProviderOption { + return { + name: 'playwright', + supportedBrowser: playwrightBrowsers, + options, + providerFactory(project) { + return new PlaywrightBrowserProvider(project, options) + }, + serverFactory: createBrowserServer, + } +} + +export class PlaywrightBrowserProvider implements BrowserProvider { + public name = 'playwright' as const + public supportsParallelism = true + + public browser: Browser | null = null + + public contexts: Map = new Map() + public pages: Map = new Map() + public mocker: BrowserModuleMocker + public browserName: PlaywrightBrowser + + private browserPromise: Promise | null = null + private closing = false + + public tracingContexts: Set = new Set() + public pendingTraces: Map = new Map() + + constructor( + private project: TestProject, + private options: PlaywrightProviderOptions, + ) { + this.browserName = project.config.browser.name as PlaywrightBrowser + this.mocker = this.createMocker() + + // make sure the traces are finished if the test hangs + process.on('SIGTERM', () => { + if (!this.browser) { + return + } + const promises = [] + for (const [trace, contextId] of this.pendingTraces.entries()) { + promises.push((() => { + const context = this.contexts.get(contextId) + return context?.tracing.stopChunk({ path: trace }) + })()) + } + return Promise.allSettled(promises) + }) + } + + private async openBrowser() { + await this._throwIfClosing() + + if (this.browserPromise) { + debug?.('[%s] the browser is resolving, reusing the promise', this.browserName) + return this.browserPromise + } + + if (this.browser) { + debug?.('[%s] the browser is resolved, reusing it', this.browserName) + return this.browser + } + + this.browserPromise = (async () => { + const options = this.project.config.browser + + const playwright = await import('playwright') + + if (this.options.connectOptions) { + if (this.options.launchOptions) { + this.project.vitest.logger.warn( + c.yellow(`Found both ${c.bold(c.italic(c.yellow('connect')))} and ${c.bold(c.italic(c.yellow('launch')))} options in browser instance configuration. + Ignoring ${c.bold(c.italic(c.yellow('launch')))} options and using ${c.bold(c.italic(c.yellow('connect')))} mode. + You probably want to remove one of the two options and keep only the one you want to use.`), + ) + } + const browser = await playwright[this.browserName].connect(this.options.connectOptions.wsEndpoint, this.options.connectOptions) + this.browser = browser + this.browserPromise = null + return this.browser + } + + const launchOptions: LaunchOptions = { + ...this.options.launchOptions, + headless: options.headless, + } + + if (typeof options.trace === 'object' && options.trace.tracesDir) { + launchOptions.tracesDir = options.trace?.tracesDir + } + + const inspector = this.project.vitest.config.inspector + if (inspector.enabled) { + // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector + const port = inspector.port || 9229 + const host = inspector.host || '127.0.0.1' + + launchOptions.args ||= [] + launchOptions.args.push(`--remote-debugging-port=${port}`) + launchOptions.args.push(`--remote-debugging-address=${host}`) + + this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`) + } + + // start Vitest UI maximized only on supported browsers + if (this.project.config.browser.ui && this.browserName === 'chromium') { + if (!launchOptions.args) { + launchOptions.args = [] + } + if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) { + launchOptions.args.push('--start-maximized') + } + } + + debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions) + this.browser = await playwright[this.browserName].launch(launchOptions) + this.browserPromise = null + return this.browser + })() + + return this.browserPromise + } + + private createMocker(): BrowserModuleMocker { + const idPreficates = new Map boolean>() + const sessionIds = new Map() + + function createPredicate(sessionId: string, url: string) { + const moduleUrl = new URL(url, 'http://localhost') + const predicate = (url: URL) => { + if (url.searchParams.has('_vitest_original')) { + return false + } + + // different modules, ignore request + if (url.pathname !== moduleUrl.pathname) { + return false + } + + url.searchParams.delete('t') + url.searchParams.delete('v') + url.searchParams.delete('import') + + // different search params, ignore request + if (url.searchParams.size !== moduleUrl.searchParams.size) { + return false + } + + // check that all search params are the same + for (const [param, value] of url.searchParams.entries()) { + if (moduleUrl.searchParams.get(param) !== value) { + return false + } + } + + return true + } + const ids = sessionIds.get(sessionId) || [] + ids.push(moduleUrl.href) + sessionIds.set(sessionId, ids) + idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate) + return predicate + } + + function predicateKey(sessionId: string, url: string) { + return `${sessionId}:${url}` + } + + return { + register: async (sessionId: string, module: MockedModule): Promise => { + const page = this.getPage(sessionId) + await page.route(createPredicate(sessionId, module.url), async (route) => { + if (module.type === 'manual') { + const exports = Object.keys(await module.resolve()) + const body = createManualModuleSource(module.url, exports) + return route.fulfill({ + body, + headers: getHeaders(this.project.browser!.vite.config), + }) + } + + // webkit doesn't support redirect responses + // https://github.com/microsoft/playwright/issues/18318 + const isWebkit = this.browserName === 'webkit' + if (isWebkit) { + let url: string + if (module.type === 'redirect') { + const redirect = new URL(module.redirect) + url = redirect.href.slice(redirect.origin.length) + } + else { + const request = new URL(route.request().url()) + request.searchParams.set('mock', module.type) + url = request.href.slice(request.origin.length) + } + + const result = await this.project.browser!.vite.transformRequest(url).catch(() => null) + if (!result) { + return route.continue() + } + let content = result.code + if (result.map && 'version' in result.map && result.map.mappings) { + const type = isDirectCSSRequest(url) ? 'css' : 'js' + content = getCodeWithSourcemap(type, content.toString(), result.map) + } + return route.fulfill({ + body: content, + headers: getHeaders(this.project.browser!.vite.config), + }) + } + + if (module.type === 'redirect') { + return route.fulfill({ + status: 302, + headers: { + Location: module.redirect, + }, + }) + } + else if (module.type === 'automock' || module.type === 'autospy') { + const url = new URL(route.request().url()) + url.searchParams.set('mock', module.type) + return route.fulfill({ + status: 302, + headers: { + Location: url.href, + }, + }) + } + else { + // all types are exhausted + const _module: never = module + } + }) + }, + delete: async (sessionId: string, id: string): Promise => { + const page = this.getPage(sessionId) + const key = predicateKey(sessionId, id) + const predicate = idPreficates.get(key) + if (predicate) { + await page.unroute(predicate).finally(() => idPreficates.delete(key)) + } + }, + clear: async (sessionId: string): Promise => { + const page = this.getPage(sessionId) + const ids = sessionIds.get(sessionId) || [] + const promises = ids.map((id) => { + const key = predicateKey(sessionId, id) + const predicate = idPreficates.get(key) + if (predicate) { + return page.unroute(predicate).finally(() => idPreficates.delete(key)) + } + return null + }) + await Promise.all(promises).finally(() => sessionIds.delete(sessionId)) + }, + } + } + + private async createContext(sessionId: string) { + await this._throwIfClosing() + + if (this.contexts.has(sessionId)) { + debug?.('[%s][%s] the context already exists, reusing it', sessionId, this.browserName) + return this.contexts.get(sessionId)! + } + + const browser = await this.openBrowser() + await this._throwIfClosing(browser) + const actionTimeout = this.options.actionTimeout + const contextOptions = this.options.contextOptions ?? {} + const options = { + ...contextOptions, + ignoreHTTPSErrors: true, + } satisfies BrowserContextOptions + if (this.project.config.browser.ui) { + options.viewport = null + } + const context = await browser.newContext(options) + await this._throwIfClosing(context) + if (actionTimeout != null) { + context.setDefaultTimeout(actionTimeout) + } + debug?.('[%s][%s] the context is ready', sessionId, this.browserName) + this.contexts.set(sessionId, context) + return context + } + + public getPage(sessionId: string): Page { + const page = this.pages.get(sessionId) + if (!page) { + throw new Error(`Page "${sessionId}" not found in ${this.browserName} browser.`) + } + return page + } + + public getCommandsContext(sessionId: string): { + page: Page + context: BrowserContext + frame: () => Promise + readonly iframe: FrameLocator + } { + const page = this.getPage(sessionId) + return { + page, + context: this.contexts.get(sessionId)!, + frame(): Promise { + return new Promise((resolve, reject) => { + const frame = page.frame('vitest-iframe') + if (frame) { + return resolve(frame) + } + + const timeout = setTimeout(() => { + const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`) + reject(err) + }, 1000).unref() + page.on('frameattached', (frame) => { + clearTimeout(timeout) + resolve(frame) + }) + }) + }, + get iframe(): FrameLocator { + return page.frameLocator('[data-vitest="true"]')! + }, + } + } + + private async openBrowserPage(sessionId: string) { + await this._throwIfClosing() + + if (this.pages.has(sessionId)) { + debug?.('[%s][%s] the page already exists, closing the old one', sessionId, this.browserName) + const page = this.pages.get(sessionId)! + await page.close() + this.pages.delete(sessionId) + } + + const context = await this.createContext(sessionId) + const page = await context.newPage() + debug?.('[%s][%s] the page is ready', sessionId, this.browserName) + await this._throwIfClosing(page) + this.pages.set(sessionId, page) + + if (process.env.VITEST_PW_DEBUG) { + page.on('requestfailed', (request) => { + console.error( + '[PW Error]', + request.resourceType(), + 'request failed for', + request.url(), + 'url:', + request.failure()?.errorText, + ) + }) + } + + return page + } + + async openPage(sessionId: string, url: string, beforeNavigate?: () => Promise): Promise { + 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) + } + + private async _throwIfClosing(disposable?: { close: () => Promise }) { + if (this.closing) { + debug?.('[%s] provider was closed, cannot perform the action on %s', this.browserName, String(disposable)) + await disposable?.close() + this.pages.clear() + this.contexts.clear() + this.browser = null + this.browserPromise = null + throw new Error(`[vitest] The provider was closed.`) + } + } + + async getCDPSession(sessionid: string): Promise { + const page = this.getPage(sessionid) + const cdp = await page.context().newCDPSession(page) + return { + async send(method: string, params: any) { + const result = await cdp.send(method as 'DOM.querySelector', params) + return result as unknown + }, + on(event: string, listener: (...args: any[]) => void) { + cdp.on(event as 'Accessibility.loadComplete', listener) + }, + off(event: string, listener: (...args: any[]) => void) { + cdp.off(event as 'Accessibility.loadComplete', listener) + }, + once(event: string, listener: (...args: any[]) => void) { + cdp.once(event as 'Accessibility.loadComplete', listener) + }, + } + } + + async close(): Promise { + debug?.('[%s] closing provider', this.browserName) + this.closing = true + if (this.browserPromise) { + await this.browserPromise + this.browserPromise = null + } + const browser = this.browser + this.browser = null + await Promise.all([...this.pages.values()].map(p => p.close())) + this.pages.clear() + await Promise.all([...this.contexts.values()].map(c => c.close())) + this.contexts.clear() + await browser?.close() + debug?.('[%s] provider is closed', this.browserName) + } +} + +function getHeaders(config: ResolvedConfig) { + const headers: Record = { + 'Content-Type': 'application/javascript', + } + + for (const name in config.server.headers) { + headers[name] = String(config.server.headers[name]!) + } + return headers +} + +function getCodeWithSourcemap( + type: 'js' | 'css', + code: string, + map: SourceMap, +): string { + if (type === 'js') { + code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}` + } + else if (type === 'css') { + code += `\n/*# sourceMappingURL=${genSourceMapUrl(map)} */` + } + + return code +} + +function genSourceMapUrl(map: SourceMap | string): string { + if (typeof map !== 'string') { + map = JSON.stringify(map) + } + return `data:application/json;base64,${Buffer.from(map).toString('base64')}` +} + +const directRequestRE = /[?&]direct\b/ + +function isDirectCSSRequest(request: string): boolean { + return isCSSRequest(request) && directRequestRE.test(request) +} + +declare module 'vitest/node' { + export interface BrowserCommandContext { + page: Page + frame(): Promise + iframe: FrameLocator + context: BrowserContext + } + + export interface ToMatchScreenshotOptions + extends Omit< + ScreenshotMatcherOptions, + 'comparatorName' | 'comparatorOptions' + > {} + + export interface ToMatchScreenshotComparators + extends ScreenshotComparatorRegistry {} +} + +type PWHoverOptions = NonNullable[1]> +type PWClickOptions = NonNullable[1]> +type PWDoubleClickOptions = NonNullable[1]> +type PWFillOptions = NonNullable[2]> +type PWScreenshotOptions = NonNullable[0]> +type PWSelectOptions = NonNullable[2]> +type PWDragAndDropOptions = NonNullable[2]> +type PWSetInputFiles = NonNullable[2]> + +declare module '@vitest/browser/context' { + export interface UserEventHoverOptions extends PWHoverOptions {} + export interface UserEventClickOptions extends PWClickOptions {} + export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {} + export interface UserEventTripleClickOptions extends PWClickOptions {} + export interface UserEventFillOptions extends PWFillOptions {} + export interface UserEventSelectOptions extends PWSelectOptions {} + export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {} + export interface UserEventUploadOptions extends PWSetInputFiles {} + + export interface ScreenshotOptions extends Omit { + mask?: ReadonlyArray | undefined + } + + export interface CDPSession { + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise + on( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this + once( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this + off( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this + } +} diff --git a/packages/browser-playwright/tsconfig.json b/packages/browser-playwright/tsconfig.json new file mode 100644 index 000000000000..89c57bbd409f --- /dev/null +++ b/packages/browser-playwright/tsconfig.json @@ -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" + ] +} diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 1b5bdd83fd40..b785032a336c 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -732,6 +732,28 @@ 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/context' + * + * declare module '@vitest/browser/context' { + * 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) => ReturnType | string }): void diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index bcad032e75cd..95025fbbbc3b 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -72,7 +72,7 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv name: 'playwright', supportedBrowser: playwrightBrowsers, options, - factory(project) { + providerFactory(project) { return new PlaywrightBrowserProvider(project, options) }, // --browser.provider=playwright diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index cc1b525810ba..7e648122c87f 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -4,7 +4,7 @@ export function preview(): BrowserProviderOption { return { name: 'preview', options: {}, - factory(project) { + providerFactory(project) { return new PreviewBrowserProvider(project) }, // --browser.provider=preview diff --git a/packages/browser/src/node/providers/webdriverio.ts b/packages/browser/src/node/providers/webdriverio.ts index 2a190f9e8f3c..c5b985348231 100644 --- a/packages/browser/src/node/providers/webdriverio.ts +++ b/packages/browser/src/node/providers/webdriverio.ts @@ -27,7 +27,7 @@ export function webdriverio(options: WebdriverProviderOptions = {}): BrowserProv name: 'webdriverio', supportedBrowser: webdriverBrowsers, options, - factory(project) { + providerFactory(project) { return new WebdriverBrowserProvider(project, options) }, // --browser.provider=webdriverio diff --git a/packages/browser/src/node/utils.ts b/packages/browser/src/node/utils.ts index 306d8292044e..71ed636a8a56 100644 --- a/packages/browser/src/node/utils.ts +++ b/packages/browser/src/node/utils.ts @@ -20,14 +20,14 @@ export async function getBrowserProvider( options.provider == null // the provider is provided via `--browser.provider=playwright` // or the config was serialized, but we can infer the factory by the name - || ('_cli' in options.provider && typeof options.provider.factory !== 'function') + || ('_cli' in options.provider && typeof options.provider.providerFactory !== 'function') ) { const providers = await import('./providers/index') const name = (options.provider?.name || 'preview') as 'preview' | 'webdriverio' | 'playwright' if (!(name in providers)) { throw new Error(`Unknown browser provider "${name}". Available providers: ${Object.keys(providers).join(', ')}.`) } - return (providers[name] as (options?: object) => BrowserProviderOption)(options.provider?.options).factory(project) + return (providers[name] as (options?: object) => BrowserProviderOption)(options.provider?.options).providerFactory(project) } const supportedBrowsers = options.provider.supportedBrowser || [] if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { @@ -37,10 +37,10 @@ export async function getBrowserProvider( }". Supported browsers: ${supportedBrowsers.join(', ')}.`, ) } - if (typeof options.provider.factory !== 'function') { - throw new TypeError(`The "${name}" browser provider does not provide a "factory" function. Received ${typeof options.provider.factory}.`) + if (typeof options.provider.providerFactory !== 'function') { + throw new TypeError(`The "${name}" browser provider does not provide a "factory" function. Received ${typeof options.provider.providerFactory}.`) } - return options.provider.factory(project) + return options.provider.providerFactory(project) } export function slash(path: string): string { diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index b98c26ed1438..840c4658dcd1 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -453,31 +453,14 @@ export class TestProject { if (!this.isBrowserEnabled() || this._parentBrowser) { return } - await this.vitest.packageInstaller.ensureInstalled( - '@vitest/browser', - this.config.root, - this.vitest.version, - ) - const { createBrowserServer, distRoot } = await import('@vitest/browser') - let cacheDir: string - const browser = await createBrowserServer( + if (typeof this.config.browser.provider!.serverFactory !== 'function') { + throw new TypeError(`The provider options do not return a "serverFactory" function.`) + } + const browser = await this.config.browser.provider!.serverFactory( this, this.vite.config.configFile, [ - { - name: 'vitest:browser-cacheDir', - configResolved(config) { - cacheDir = config.cacheDir - }, - }, - ...MocksPlugins({ - filter(id) { - if (id.includes(distRoot) || id.includes(cacheDir)) { - return false - } - return true - }, - }), + ...MocksPlugins(), // TODO: inject cacheDir inside the server factory MetaEnvReplacerPlugin(), ], [CoverageTransform(this.vitest)], diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 7c9a34dee9cd..f90ffdb7b978 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -2,7 +2,7 @@ import type { MockedModule } from '@vitest/mocker' import type { CancelReason } from '@vitest/runner' import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' import type { StackTraceParserOptions } from '@vitest/utils/source-map' -import type { ViteDevServer } from 'vite' +import type { Plugin, ViteDevServer } from 'vite' import type { BrowserTraceViewMode } from '../../runtime/config' import type { BrowserTesterOptions } from '../../types/browser' import type { TestProject } from '../project' @@ -25,7 +25,13 @@ export interface BrowserProviderOption { name: string supportedBrowser?: ReadonlyArray options: Options - factory: (project: TestProject) => BrowserProvider + providerFactory: (project: TestProject) => BrowserProvider + serverFactory: ( + project: TestProject, + configFile: string | undefined, + prePlugins: Plugin[], + postPlugins: Plugin[], + ) => Promise } export interface BrowserProvider { @@ -285,6 +291,7 @@ export interface BrowserServerState { export interface ParentProjectBrowser { spawn: (project: TestProject) => ProjectBrowser + vite: ViteDevServer } export interface ProjectBrowser { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e72e38aa97bc..f082329befdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,6 +509,28 @@ importers: specifier: ^9.19.2 version: 9.19.2 + packages/browser-playwright: + dependencies: + '@vitest/browser': + specifier: workspace:* + version: link:../browser + '@vitest/mocker': + specifier: workspace:* + version: link:../mocker + tinyrainbow: + specifier: 'catalog:' + version: 3.0.3 + devDependencies: + playwright: + specifier: ^1.55.0 + version: 1.55.0 + playwright-core: + specifier: ^1.55.0 + version: 1.55.0 + vitest: + specifier: workspace:* + version: link:../vitest + packages/coverage-istanbul: dependencies: '@istanbuljs/schema': From cd067e5ac5e7de92d95477ce9fa505c1d8d20f7d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 10:42:25 +0200 Subject: [PATCH 02/43] feat: support "vitest/browser" --- packages/browser-playwright/src/provider.ts | 3 +- packages/browser/context.d.ts | 9 +- packages/vitest/browser/aria-role.d.ts | 96 +++ packages/vitest/browser/context.d.ts | 768 ++++++++++++++++++++ packages/vitest/browser/context.js | 19 + packages/vitest/browser/jest-dom.d.ts | 724 ++++++++++++++++++ packages/vitest/browser/matchers.d.ts | 29 + packages/vitest/package.json | 4 + 8 files changed, 1647 insertions(+), 5 deletions(-) create mode 100644 packages/vitest/browser/aria-role.d.ts create mode 100644 packages/vitest/browser/context.d.ts create mode 100644 packages/vitest/browser/context.js create mode 100644 packages/vitest/browser/jest-dom.d.ts create mode 100644 packages/vitest/browser/matchers.d.ts diff --git a/packages/browser-playwright/src/provider.ts b/packages/browser-playwright/src/provider.ts index 49aed2d3d345..e3db6742d923 100644 --- a/packages/browser-playwright/src/provider.ts +++ b/packages/browser-playwright/src/provider.ts @@ -431,10 +431,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return page } - async openPage(sessionId: string, url: string, beforeNavigate?: () => Promise): Promise { + async openPage(sessionId: string, url: string): Promise { 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) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index b785032a336c..d428ee49cfeb 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -739,9 +739,9 @@ export interface BrowserLocators { * * @example * ```ts - * import { locators } from '@vitest/browser/context' + * import { locators } from 'vitest/browser' * - * declare module '@vitest/browser/context' { + * declare module 'vitest/browser' { * interface LocatorSelectors { * getByCSS(css: string): Locator * } @@ -755,7 +755,10 @@ export interface BrowserLocators { * ``` */ extend(methods: { - [K in keyof LocatorSelectors]?: (this: BrowserPage | Locator, ...args: Parameters) => ReturnType | string + [K in keyof LocatorSelectors]?: ( + this: BrowserPage | Locator, + ...args: Parameters + ) => ReturnType | string }): void } diff --git a/packages/vitest/browser/aria-role.d.ts b/packages/vitest/browser/aria-role.d.ts new file mode 100644 index 000000000000..8818dd8b34b5 --- /dev/null +++ b/packages/vitest/browser/aria-role.d.ts @@ -0,0 +1,96 @@ +type ARIAWidgetRole = + | "button" + | "checkbox" + | "gridcell" + | "link" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "option" + | "progressbar" + | "radio" + | "scrollbar" + | "searchbox" + | "slider" + | "spinbutton" + | "switch" + | "tab" + | "tabpanel" + | "textbox" + | "treeitem"; + +type ARIACompositeWidgetRole = + | "combobox" + | "grid" + | "listbox" + | "menu" + | "menubar" + | "radiogroup" + | "tablist" + | "tree" + | "treegrid"; + +type ARIADocumentStructureRole = + | "application" + | "article" + | "blockquote" + | "caption" + | "cell" + | "columnheader" + | "definition" + | "deletion" + | "directory" + | "document" + | "emphasis" + | "feed" + | "figure" + | "generic" + | "group" + | "heading" + | "img" + | "insertion" + | "list" + | "listitem" + | "math" + | "meter" + | "none" + | "note" + | "paragraph" + | "presentation" + | "row" + | "rowgroup" + | "rowheader" + | "separator" + | "strong" + | "subscript" + | "superscript" + | "table" + | "term" + | "time" + | "toolbar" + | "tooltip"; + +type ARIALandmarkRole = + | "banner" + | "complementary" + | "contentinfo" + | "form" + | "main" + | "navigation" + | "region" + | "search"; + +type ARIALiveRegionRole = "alert" | "log" | "marquee" | "status" | "timer"; + +type ARIAWindowRole = "alertdialog" | "dialog"; + +type ARIAUncategorizedRole = "code"; + +export type ARIARole = + | ARIAWidgetRole + | ARIACompositeWidgetRole + | ARIADocumentStructureRole + | ARIALandmarkRole + | ARIALiveRegionRole + | ARIAWindowRole + | ARIAUncategorizedRole; diff --git a/packages/vitest/browser/context.d.ts b/packages/vitest/browser/context.d.ts new file mode 100644 index 000000000000..d428ee49cfeb --- /dev/null +++ b/packages/vitest/browser/context.d.ts @@ -0,0 +1,768 @@ +import { SerializedConfig } from 'vitest' +import { ARIARole } from './aria-role.js' +import {} from './matchers.js' + +export type BufferEncoding = + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'utf-16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' + +export interface FsOptions { + encoding?: BufferEncoding + flag?: string | number +} + +export interface CDPSession { + // methods are defined by the provider type augmentation +} + +export interface ScreenshotOptions { + element?: Element | Locator + /** + * Path relative to the current test file. + * @default `__screenshots__/${testFileName}/${testName}.png` + */ + path?: string + /** + * Will also return the base64 encoded screenshot alongside the path. + */ + base64?: boolean + /** + * Keep the screenshot on the file system. If file is not saved, + * `page.screenshot` always returns `base64` screenshot. + * @default true + */ + save?: boolean +} + +export interface ScreenshotComparatorRegistry { + pixelmatch: { + /** + * The maximum number of pixels that are allowed to differ between the captured + * screenshot and the stored reference image. + * + * If set to `undefined`, any non-zero difference will cause the test to fail. + * + * For example, `allowedMismatchedPixels: 10` means the test will pass if 10 + * or fewer pixels differ, but fail if 11 or more differ. + * + * If both this and `allowedMismatchedPixelRatio` are set, the more restrictive + * value (i.e., fewer allowed mismatches) will be used. + * + * @default undefined + */ + allowedMismatchedPixels?: number | undefined + /** + * The maximum allowed ratio of differing pixels between the captured screenshot + * and the reference image. + * + * Must be a value between `0` and `1`. + * + * For example, `allowedMismatchedPixelRatio: 0.02` means the test will pass + * if up to 2% of pixels differ, but fail if more than 2% differ. + * + * If both this and `allowedMismatchedPixels` are set, the more restrictive + * value (i.e., fewer allowed mismatches) will be used. + * + * @default undefined + */ + allowedMismatchedPixelRatio?: number | undefined + /** + * Acceptable perceived color difference between the same pixel in two images. + * + * Value ranges from `0` (strict) to `1` (very lenient). Lower values mean + * small differences will be detected. + * + * The comparison uses the {@link https://en.wikipedia.org/wiki/YIQ | YIQ color space}. + * + * @default 0.1 + */ + threshold?: number | undefined + /** + * If `true`, disables detection and ignoring of anti-aliased pixels. + * + * @default false + */ + includeAA?: boolean | undefined + /** + * Blending level of unchanged pixels in the diff image. + * + * Ranges from `0` (white) to `1` (original brightness). + * + * @default 0.1 + */ + alpha?: number | undefined + /** + * Color used for anti-aliased pixels in the diff image. + * + * Format: `[R, G, B]` + * + * @default [255, 255, 0] + */ + aaColor?: [r: number, g: number, b: number] | undefined + /** + * Color used for differing pixels in the diff image. + * + * Format: `[R, G, B]` + * + * @default [255, 0, 0] + */ + diffColor?: [r: number, g: number, b: number] | undefined + /** + * Optional alternative color for dark-on-light differences, to help show + * what's added vs. removed. + * + * If not set, `diffColor` is used for all differences. + * + * Format: `[R, G, B]` + * + * @default undefined + */ + diffColorAlt?: [r: number, g: number, b: number] | undefined + /** + * If `true`, shows only the diff as a mask on a transparent background, + * instead of overlaying it on the original image. + * + * Anti-aliased pixels won't be shown (if detected). + * + * @default false + */ + diffMask?: boolean | undefined + } +} + +export interface ScreenshotMatcherOptions< + ComparatorName extends keyof ScreenshotComparatorRegistry = keyof ScreenshotComparatorRegistry +> { + /** + * The name of the comparator to use for visual diffing. + * + * Must be one of the keys from {@linkcode ScreenshotComparatorRegistry}. + * + * @defaultValue `'pixelmatch'` + */ + comparatorName?: ComparatorName + comparatorOptions?: ScreenshotComparatorRegistry[ComparatorName] + screenshotOptions?: Omit< + ScreenshotOptions, + 'element' | 'base64' | 'path' | 'save' | 'type' + > + /** + * Time to wait until a stable screenshot is found. + * + * Setting this value to `0` disables the timeout, but if a stable screenshot + * can't be determined the process will not end. + * + * @default 5000 + */ + timeout?: number +} + +export interface BrowserCommands { + readFile: ( + path: string, + options?: BufferEncoding | FsOptions + ) => Promise + writeFile: ( + path: string, + content: string, + options?: BufferEncoding | (FsOptions & { mode?: number | string }) + ) => Promise + removeFile: (path: string) => Promise +} + +export interface 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. + * + * **Note:** Unlike `@testing-library/user-event`, the default `userEvent` instance + * from `@vitest/browser/context` is created once, not every time its methods are called! + * @see {@link https://vitest.dev/guide/browser/interactivity-api.html#userevent-setup} + */ + setup: () => UserEvent + /** + * Cleans up the user event instance, releasing any resources or state it holds, + * such as keyboard press state. For the default `userEvent` instance, this method + * is automatically called after each test case. + */ + cleanup: () => Promise + /** + * Click on an element. Uses provider's API under the hood and supports all its options. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API + * @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API + */ + click: (element: Element | Locator, options?: UserEventClickOptions) => Promise + /** + * Triggers a double click event on an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-dblclick} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#dblClick} testing-library API + */ + dblClick: (element: Element | Locator, options?: UserEventDoubleClickOptions) => Promise + /** + * Triggers a triple click event on an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API: using `click` with `clickCount: 3` + * @see {@link https://webdriver.io/docs/api/browser/actions/} WebdriverIO API: using actions api with `move` plus three `down + up + pause` events in a row + * @see {@link https://testing-library.com/docs/user-event/convenience/#tripleclick} testing-library API + */ + tripleClick: (element: Element | Locator, options?: UserEventTripleClickOptions) => Promise + /** + * Choose one or more values from a select element. Uses provider's API under the hood. + * If select doesn't have `multiple` attribute, only the first value will be selected. + * @example + * await userEvent.selectOptions(select, 'Option 1') + * expect(select).toHaveValue('option-1') + * + * await userEvent.selectOptions(select, 'option-1') + * expect(select).toHaveValue('option-1') + * + * await userEvent.selectOptions(select, [ + * screen.getByRole('option', { name: 'Option 1' }), + * screen.getByRole('option', { name: 'Option 2' }), + * ]) + * expect(select).toHaveValue(['option-1', 'option-2']) + * @see {@link https://playwright.dev/docs/api/class-locator#locator-select-option} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions} testing-library API + */ + selectOptions: ( + element: Element, + values: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], + options?: UserEventSelectOptions, + ) => Promise + /** + * Type text on the keyboard. If any input is focused, it will receive the text, + * otherwise it will be typed on the document. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.keyboard('foo') // translates to: f, o, o + * await userEvent.keyboard('{{a[[') // translates to: {, a, [ + * await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API + */ + keyboard: (text: string) => Promise + /** + * Types text into an element. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * This method can be significantly slower than `userEvent.fill`, so it should be used only when necessary. + * @example + * await userEvent.type(input, 'foo') // translates to: f, o, o + * await userEvent.type(input, '{{a[[') // translates to: {, a, [ + * await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/action#key-input-source} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + type: (element: Element | Locator, text: string, options?: UserEventTypeOptions) => Promise + /** + * Removes all text from an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API + * @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API + */ + clear: (element: Element | Locator, options?: UserEventClearOptions) => Promise + /** + * Sends a `Tab` key event. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API + */ + tab: (options?: UserEventTabOptions) => Promise + /** + * Hovers over an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + hover: (element: Element | Locator, options?: UserEventHoverOptions) => Promise + /** + * Moves cursor position to the body element. Uses provider's API under the hood. + * By default, the cursor position is in the center (in webdriverio) or in some visible place (in playwright) + * of the body element, so if the current element is already there, this will have no effect. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + unhover: (element: Element | Locator, options?: UserEventHoverOptions) => Promise + /** + * Change a file input element to have the specified files. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-set-input-files} Playwright API + * @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API + */ + upload: (element: Element | Locator, files: File | File[] | string | string[], options?: UserEventUploadOptions) => Promise + /** + * Copies the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#copy} testing-library API + */ + copy: () => Promise + /** + * Cuts the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#cut} testing-library API + */ + cut: () => Promise + /** + * Pastes the copied or cut content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#paste} testing-library API + */ + paste: () => Promise + /** + * Fills an input element with text. This will remove any existing text in the input before typing the new text. + * Uses provider's API under the hood. + * This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). + * @example + * await userEvent.fill(input, 'foo') // translates to: f, o, o + * await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [ + * await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, } + * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API + * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + fill: (element: Element | Locator, text: string, options?: UserEventFillOptions) => Promise + /** + * Drags a source element on top of the target element. This API is not supported by "preview" provider. + * @see {@link https://playwright.dev/docs/api/class-frame#frame-drag-and-drop} Playwright API + * @see {@link https://webdriver.io/docs/api/element/dragAndDrop/} WebdriverIO API + */ + dragAndDrop: (source: Element | Locator, target: Element | Locator, options?: UserEventDragAndDropOptions) => Promise +} + +export interface UserEventFillOptions {} +export interface UserEventHoverOptions {} +export interface UserEventSelectOptions {} +export interface UserEventClickOptions {} +export interface UserEventClearOptions {} +export interface UserEventDoubleClickOptions {} +export interface UserEventTripleClickOptions {} +export interface UserEventDragAndDropOptions {} +export interface UserEventUploadOptions {} + +export interface LocatorOptions { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a + * regular expression. Note that exact match still trims whitespace. + */ + exact?: boolean +} + +export interface LocatorByRoleOptions extends LocatorOptions { + /** + * Should checked elements (set by `aria-checked` or ``) be included or not. By default, the filter is not applied. + * + * See [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked) for more information + */ + checked?: boolean + /** + * Should disabled elements be included or not. By default, the filter is not applied. Note that unlike other attributes, `disable` state is inherited. + * + * See [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled) for more information + */ + disabled?: boolean + /** + * Should expanded elements be included or not. By default, the filter is not applied. + * + * See [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded) for more information + */ + expanded?: boolean + /** + * Should elements that are [normally excluded](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion) from the accessibility tree be queried. By default, only non-hidden elements are matched by role selector. + * + * Note that roles `none` and `presentation` are always included. + * @default false + */ + includeHidden?: boolean + /** + * A number attribute that is usually present for `heading`, `listitem`, `row`, `treeitem` roles with default values for `

-

` elements. By default, the filter is not applied. + * + * See [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level) for more information + */ + level?: number + /** + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. + */ + name?: string | RegExp + /** + * Should pressed elements be included or not. By default, the filter is not applied. + * + * See [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed) for more information + */ + pressed?: boolean + /** + * Should selected elements be included or not. By default, the filter is not applied. + * + * See [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected) for more information + */ + selected?: boolean +} + +interface LocatorScreenshotOptions extends Omit {} + +interface LocatorSelectors { + /** + * Creates a way to locate an element by its [ARIA role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles), [ARIA attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes) and [accessible name](https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name). + * @see {@link https://vitest.dev/guide/browser/locators#getbyrole} + */ + getByRole: (role: ARIARole | ({} & string), options?: LocatorByRoleOptions) => Locator + /** + * @see {@link https://vitest.dev/guide/browser/locators#getbylabeltext} + */ + getByLabelText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element with an `alt` attribute that matches the text. Unlike testing-library's implementation, Vitest will match any element that has an `alt` attribute. + * @see {@link https://vitest.dev/guide/browser/locators#getbyalttext} + */ + getByAltText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that has the specified placeholder text. Vitest will match any element that has a matching `placeholder` attribute, not just `input`. + * @see {@link https://vitest.dev/guide/browser/locators#getbyplaceholder} + */ + getByPlaceholder: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that contains the specified text. The text will be matched against TextNode's [`nodeValue`](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue) or input's value if the type is `button` or `reset`. + * Matching by text always normalizes whitespace, even with exact match. + * For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace. + * @see {@link https://vitest.dev/guide/browser/locators#getbytext} + */ + getByText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that has the specified `title` attribute. Unlike testing-library's `getByTitle`, Vitest cannot find `title` elements within an SVG. + * @see {@link https://vitest.dev/guide/browser/locators#getbytitle} + */ + getByTitle: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/config/#browser-locators-testidattribute). + * @see {@link https://vitest.dev/guide/browser/locators#getbytestid} + */ + getByTestId: (text: string | RegExp) => Locator +} + +export interface FrameLocator extends LocatorSelectors {} + +export interface Locator extends LocatorSelectors { + /** + * Selector string that will be used to locate the element by the browser provider. + * You can use this string in the commands API: + * ```ts + * // playwright + * function test({ selector, iframe }) { + * await iframe.locator(selector).click() + * } + * // webdriverio + * function test({ selector, browser }) { + * await browser.$(selector).click() + * } + * ``` + * @see {@link https://vitest.dev/guide/browser/locators#selector} + */ + readonly selector: string + + /** + * The number of elements that this locator is matching. + * @see {@link https://vitest.dev/guide/browser/locators#length} + */ + readonly length: number + + /** + * Click on an element. You can use the options to set the cursor position. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-click} + */ + click(options?: UserEventClickOptions): Promise + /** + * Triggers a double click event on an element. You can use the options to set the cursor position. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dblclick} + */ + dblClick(options?: UserEventDoubleClickOptions): Promise + /** + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-tripleclick} + */ + tripleClick(options?: UserEventTripleClickOptions): Promise + /** + * Clears the input element content + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear} + */ + clear(options?: UserEventClearOptions): Promise + /** + * Moves the cursor position to the selected element + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover} + */ + hover(options?: UserEventHoverOptions): Promise + /** + * This works the same as `locator.hover`, but moves the cursor to the `document.body` element instead. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-unhover} + */ + unhover(options?: UserEventHoverOptions): Promise + /** + * Sets the value of the current `input`, `textarea` or `contenteditable` element. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-fill} + */ + fill(text: string, options?: UserEventFillOptions): Promise + /** + * Drags the current element to the target location. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dropto} + */ + dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise + /** + * Choose one or more values from a ` + * + *
+ * + *
+ * + * await expect(page.getByTestId('no-aria-invalid')).not.toBeInvalid() + * await expect(page.getByTestId('invalid-form')).toBeInvalid() + * @see https://vitest.dev/guide/browser/assertion-api#tobeinvalid + */ + toBeInvalid(): R + /** + * @description + * This allows you to check if a form element is currently required. + * + * An element is required if it is having a `required` or `aria-required="true"` attribute. + * @example + * + *
+ * + * await expect.element(page.getByTestId('required-input')).toBeRequired() + * await expect.element(page.getByTestId('supported-role')).not.toBeRequired() + * @see https://vitest.dev/guide/browser/assertion-api#toberequired + */ + toBeRequired(): R + /** + * @description + * Allows you to check if a form element is currently required. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "false", or if the result of `checkValidity()` is true. + * @example + * + * + *
+ * + *
+ * + * await expect.element(page.getByTestId('no-aria-invalid')).not.toBeValid() + * await expect.element(page.getByTestId('invalid-form')).toBeInvalid() + * @see https://vitest.dev/guide/browser/assertion-api#tobevalid + */ + toBeValid(): R + /** + * @description + * Allows you to assert whether an element contains another element as a descendant or not. + * @example + * + * + * + * + * const ancestor = page.getByTestId('ancestor') + * const descendant = page.getByTestId('descendant') + * const nonExistentElement = page.getByTestId('does-not-exist') + * await expect.element(ancestor).toContainElement(descendant) + * await expect.element(descendant).not.toContainElement(ancestor) + * await expect.element(ancestor).not.toContainElement(nonExistentElement) + * @see https://vitest.dev/guide/browser/assertion-api#tocontainelement + */ + toContainElement(element: HTMLElement | SVGElement | null): R + /** + * @description + * Assert whether a string representing a HTML element is contained in another element. + * @example + * + * + * const parent = page.getByTestId('parent') + * await expect.element(parent).toContainHTML('') + * @see https://vitest.dev/guide/browser/assertion-api#tocontainhtml + */ + toContainHTML(htmlText: string): R + /** + * @description + * Allows you to check if a given element has an attribute or not. + * + * You can also optionally check that the attribute has a specific expected value or partial match using + * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or + * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + * @example + * + * + * await expect.element(button).toHaveAttribute('disabled') + * await expect.element(button).toHaveAttribute('type', 'submit') + * await expect.element(button).not.toHaveAttribute('type', 'button') + * @see https://vitest.dev/guide/browser/assertion-api#tohaveattribute + */ + toHaveAttribute(attr: string, value?: unknown): R + /** + * @description + * Check whether the given element has certain classes within its `class` attribute. + * + * You must provide at least one class, unless you are asserting that an element does not have any classes. + * @example + * + * + *
no classes
+ * + * const deleteButton = page.getByTestId('delete-button') + * const noClasses = page.getByTestId('no-classes') + * await expect.element(deleteButton).toHaveClass('btn') + * await expect.element(deleteButton).toHaveClass('btn-danger xs') + * await expect.element(deleteButton).toHaveClass(/danger/, 'xs') + * await expect.element(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) + * await expect.element(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) + * await expect.element(noClasses).not.toHaveClass() + * @see https://vitest.dev/guide/browser/assertion-api#tohaveclass + */ + toHaveClass(...classNames: + | (string | RegExp)[] + | [string, options?: {exact: boolean}] + | [string, string, options?: {exact: boolean}] + | [string, string, string, options?: {exact: boolean}] + | [string, string, string, string, options?: {exact: boolean}] + | [string, string, string, string, string, options?: {exact: boolean}] + | [string, string, string, string, string, string, options?: {exact: boolean}] + | [string, string, string, string, string, string, string, options?: {exact: boolean}] + | [string, string, string, string, string, string, string, string, options?: {exact: boolean}] + | [string, string, string, string, string, string, string, string, string, options?: {exact: boolean}] + ): R + /** + * @description + * This allows you to check whether the given form element has the specified displayed value (the one the + * end user will see). It accepts , + * + * + * + * + * + * + * + * const input = page.getByLabelText('First name') + * const textarea = page.getByLabelText('Description') + * const selectSingle = page.getByLabelText('Fruit') + * const selectMultiple = page.getByLabelText('Fruits') + * + * await expect.element(input).toHaveDisplayValue('Luca') + * await expect.element(textarea).toHaveDisplayValue('An example description here.') + * await expect.element(selectSingle).toHaveDisplayValue('Select a fruit...') + * await expect.element(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) + * + * @see https://vitest.dev/guide/browser/assertion-api#tohavedisplayvalue + */ + toHaveDisplayValue(value: string | number | RegExp | Array): R + /** + * @description + * Assert whether an element has focus or not. + * @example + *
+ * + *
+ * + * const input = page.getByTestId('element-to-focus') + * input.element().focus() + * await expect.element(input).toHaveFocus() + * input.element().blur() + * await expect.element(input).not.toHaveFocus() + * @see https://vitest.dev/guide/browser/assertion-api#tohavefocus + */ + toHaveFocus(): R + /** + * @description + * Check if a form or fieldset contains form controls for each given name, and having the specified value. + * + * Can only be invoked on a form or fieldset element. + * @example + *
+ * + * + * + * + *
+ * + * await expect.element(page.getByTestId('login-form')).toHaveFormValues({ + * username: 'jane.doe', + * rememberMe: true, + * }) + * @see https://vitest.dev/guide/browser/assertion-api#tohaveformvalues + */ + toHaveFormValues(expectedValues: Record): R + /** + * @description + * Check if an element has specific css properties with specific values applied. + * + * Only matches if the element has *all* the expected properties applied, not just some of them. + * @example + * + * + * const button = page.getByTestId('submit-button') + * await expect.element(button).toHaveStyle('background-color: green') + * await expect.element(button).toHaveStyle({ + * 'background-color': 'green', + * display: 'none' + * }) + * @see https://vitest.dev/guide/browser/assertion-api#tohavestyle + */ + toHaveStyle(css: string | Partial): R + /** + * @description + * Check whether the given element has a text content or not. + * + * When a string argument is passed through, it will perform a partial case-sensitive match to the element + * content. + * + * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. + * + * If you want to match the whole content, you can use a RegExp to do it. + * @example + * Text Content + * + * const element = page.getByTestId('text-content') + * await expect.element(element).toHaveTextContent('Content') + * // to match the whole content + * await expect.element(element).toHaveTextContent(/^Text Content$/) + * // to use case-insensitive match + * await expect.element(element).toHaveTextContent(/content$/i) + * await expect.element(element).not.toHaveTextContent('content') + * @see https://vitest.dev/guide/browser/assertion-api#tohavetextcontent + */ + toHaveTextContent( + text: string | number | RegExp, + options?: {normalizeWhitespace: boolean}, + ): R + /** + * @description + * Check whether the given form element has the specified value. + * + * Accepts ``, ` + *

prev

+ *

text selected text

+ *

next

+ *
+ * + * page.getByTestId('text').element().setSelectionRange(5, 13) + * await expect.element(page.getByTestId('text')).toHaveSelection('selected') + * + * page.getByTestId('textarea').element().setSelectionRange(0, 5) + * await expect.element('textarea').toHaveSelection('text ') + * + * const selection = document.getSelection() + * const range = document.createRange() + * selection.removeAllRanges() + * selection.empty() + * selection.addRange(range) + * + * // selection of child applies to the parent as well + * range.selectNodeContents(page.getByTestId('child').element()) + * await expect.element(page.getByTestId('child')).toHaveSelection('selected') + * await expect.element(page.getByTestId('parent')).toHaveSelection('selected') + * + * // selection that applies from prev all, parent text before child, and part child. + * range.setStart(page.getByTestId('prev').element(), 0) + * range.setEnd(page.getByTestId('child').element().childNodes[0], 3) + * await expect.element(page.queryByTestId('prev')).toHaveSelection('prev') + * await expect.element(page.queryByTestId('child')).toHaveSelection('sel') + * await expect.element(page.queryByTestId('parent')).toHaveSelection('text sel') + * await expect.element(page.queryByTestId('next')).not.toHaveSelection() + * + * // selection that applies from part child, parent text after child and part next. + * range.setStart(page.getByTestId('child').element().childNodes[0], 3) + * range.setEnd(page.getByTestId('next').element().childNodes[0], 2) + * await expect.element(page.queryByTestId('child')).toHaveSelection('ected') + * await expect.element(page.queryByTestId('parent')).toHaveSelection('ected text') + * await expect.element(page.queryByTestId('prev')).not.toHaveSelection() + * await expect.element(page.queryByTestId('next')).toHaveSelection('ne') + * + * @see https://vitest.dev/guide/browser/assertion-api#tohaveselection + */ + toHaveSelection(selection?: string): R + + /** + * @description + * This assertion allows you to perform visual regression testing by comparing + * screenshots of elements or pages against stored reference images. + * + * When differences are detected beyond the configured threshold, the test fails. + * To help identify the changes, the assertion generates: + * + * - The actual screenshot captured during the test + * - The expected reference screenshot + * - A diff image highlighting the differences (when possible) + * + * @example + * + * + * // basic usage, auto-generates screenshot name + * await expect.element(getByTestId('button')).toMatchScreenshot() + * + * // with custom name + * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button') + * + * // with options + * await expect.element(getByTestId('button')).toMatchScreenshot({ + * comparatorName: 'pixelmatch', + * comparatorOptions: { + * allowedMismatchedPixelRatio: 0.01, + * }, + * }) + * + * // with both name and options + * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button', { + * comparatorName: 'pixelmatch', + * comparatorOptions: { + * allowedMismatchedPixelRatio: 0.01, + * }, + * }) + * + * @see https://vitest.dev/guide/browser/assertion-api#tomatchscreenshot + */ + toMatchScreenshot( + options?: ScreenshotMatcherOptions, + ): Promise + toMatchScreenshot( + name?: string, + options?: ScreenshotMatcherOptions, + ): Promise +} diff --git a/packages/vitest/browser/matchers.d.ts b/packages/vitest/browser/matchers.d.ts new file mode 100644 index 000000000000..36274e609b27 --- /dev/null +++ b/packages/vitest/browser/matchers.d.ts @@ -0,0 +1,29 @@ +import type { Locator } from 'vitest/browser' +import type { TestingLibraryMatchers } from './jest-dom.js' +import type { Assertion, ExpectPollOptions } from 'vitest' + +declare module 'vitest' { + interface JestAssertion extends TestingLibraryMatchers {} + interface AsymmetricMatchersContaining extends TestingLibraryMatchers {} + + type Promisify = { + [K in keyof O]: O[K] extends (...args: infer A) => infer R + ? O extends R + ? Promisify + : (...args: A) => Promise + : O[K]; + } + + type PromisifyDomAssertion = Promisify> + + interface ExpectStatic { + /** + * `expect.element(locator)` is a shorthand for `expect.poll(() => locator.element())`. + * You can set default timeout via `expect.poll.timeout` option in the config. + * @see {@link https://vitest.dev/api/expect#poll} + */ + element: (element: T, options?: ExpectPollOptions) => PromisifyDomAssertion> + } +} + +export {} diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 36d6c3d14506..8c42a1fd3321 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -39,6 +39,10 @@ "default": "./index.cjs" } }, + "./browser": { + "types": "./browser/context.d.ts", + "default": "./browser/context.js" + }, "./package.json": "./package.json", "./optional-types.js": { "types": "./optional-types.d.ts" From 454e3280ac05b8992e1aab858d96a2ebca6fc8c4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 10:57:50 +0200 Subject: [PATCH 03/43] fix: pass down factory options --- packages/browser/src/node/index.ts | 34 +- packages/vitest/src/node/pool.ts | 14 +- packages/vitest/src/node/pools/browser.ts | 383 ++++++++++++++++++++++ packages/vitest/src/node/project.ts | 22 +- packages/vitest/src/node/types/browser.ts | 18 +- packages/vitest/src/public/node.ts | 2 + 6 files changed, 432 insertions(+), 41 deletions(-) create mode 100644 packages/vitest/src/node/pools/browser.ts diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 1c6312a376dd..81775d4ef331 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -1,25 +1,22 @@ -import type { Plugin } from 'vitest/config' -import type { TestProject } from 'vitest/node' +import type { BrowserServerFactory } from 'vitest/node' import { MockerRegistry } from '@vitest/mocker' import { interceptorPlugin } from '@vitest/mocker/node' import c from 'tinyrainbow' import { createViteLogger, createViteServer } from 'vitest/node' import { version } from '../../package.json' +import { distRoot } from './constants' import BrowserPlugin from './plugin' import { ParentBrowserProject } from './projectParent' import { setupBrowserRpc } from './rpc' -export { distRoot } from './constants' export { createBrowserPool } from './pool' export type { ProjectBrowser } from './project' -export async function createBrowserServer( - project: TestProject, - configFile: string | undefined, - prePlugins: Plugin[] = [], - postPlugins: Plugin[] = [], -): Promise { +export const createBrowserServer: BrowserServerFactory = async (options) => { + const project = options.project + const configFile = project.vite.config.configFile + if (project.vitest.version !== version) { project.vitest.logger.warn( c.yellow( @@ -42,6 +39,7 @@ export async function createBrowserServer( const mockerRegistry = new MockerRegistry() + let cacheDir: string const vite = await createViteServer({ ...project.options, // spread project config inlined in root workspace config base: '/', @@ -71,11 +69,25 @@ export async function createBrowserServer( }, cacheDir: project.vite.config.cacheDir, plugins: [ - ...prePlugins, + { + name: 'vitest-internal:browser-cacheDir', + configResolved(config) { + cacheDir = config.cacheDir + }, + }, + ...options.mocksPlugins({ + filter(id) { + if (id.includes(distRoot) || id.includes(cacheDir)) { + return false + } + return true + }, + }), + options.metaEnvReplacer(), ...(project.options?.plugins || []), BrowserPlugin(server), interceptorPlugin({ registry: mockerRegistry }), - ...postPlugins, + options.coveragePlugin(), ], }) diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index c7040b98b1ed..ce71d3bddafa 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -8,6 +8,7 @@ import { resolve } from 'pathe' import { version as viteVersion } from 'vite' import { rootDir } from '../paths' import { isWindows } from '../utils/env' +import { createBrowserPool } from './pools/browser' import { createForksPool } from './pools/forks' import { createThreadsPool } from './pools/threads' import { createTypecheckPool } from './pools/typecheck' @@ -175,13 +176,6 @@ export function createPool(ctx: Vitest): ProcessPool { return getConcurrentPool(pool, () => resolveCustomPool(pool)) } - function getBrowserPool() { - return getConcurrentPool('browser', async () => { - const { createBrowserPool } = await import('@vitest/browser') - return createBrowserPool(ctx) - }) - } - const groupedSpecifications: Record = {} const groups = new Set() @@ -191,6 +185,7 @@ export function createPool(ctx: Vitest): ProcessPool { threads: specs => createThreadsPool(ctx, options, specs), forks: specs => createForksPool(ctx, options, specs), typescript: () => createTypecheckPool(ctx), + browser: () => createBrowserPool(ctx), } for (const spec of files) { @@ -255,11 +250,6 @@ export function createPool(ctx: Vitest): ProcessPool { return pools[pool]![method](specs, invalidate) } - if (pool === 'browser') { - pools.browser ??= await getBrowserPool() - return pools.browser[method](specs, invalidate) - } - const poolHandler = await getCustomPool(pool) pools[poolHandler.name] ??= poolHandler return poolHandler[method](specs, invalidate) diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts new file mode 100644 index 000000000000..abf24d3e6b0c --- /dev/null +++ b/packages/vitest/src/node/pools/browser.ts @@ -0,0 +1,383 @@ +import type { DeferPromise } from '@vitest/utils/helpers' +import type { Vitest } from '../core' +import type { ProcessPool } from '../pool' +import type { TestProject } from '../project' +import type { TestSpecification } from '../spec' +import type { BrowserProvider } from '../types/browser' +import crypto from 'node:crypto' +import * as nodeos from 'node:os' +import { performance } from 'node:perf_hooks' +import { createDefer } from '@vitest/utils/helpers' +import { stringify } from 'flatted' +import { createDebugger } from '../../utils/debugger' + +const debug = createDebugger('vitest:browser:pool') + +export function createBrowserPool(vitest: Vitest): ProcessPool { + const providers = new Set() + + const numCpus + = typeof nodeos.availableParallelism === 'function' + ? nodeos.availableParallelism() + : nodeos.cpus().length + + const threadsCount = vitest.config.watch + ? Math.max(Math.floor(numCpus / 2), 1) + : Math.max(numCpus - 1, 1) + + const projectPools = new WeakMap() + + const ensurePool = (project: TestProject) => { + if (projectPools.has(project)) { + return projectPools.get(project)! + } + + debug?.('creating pool for project %s', project.name) + + const resolvedUrls = project.browser!.vite.resolvedUrls + const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] + + if (!origin) { + throw new Error( + `Can't find browser origin URL for project "${project.name}"`, + ) + } + + const pool: BrowserPool = new BrowserPool(project, { + maxWorkers: getThreadsCount(project), + origin, + }) + projectPools.set(project, pool) + vitest.onCancel(() => { + pool.cancel() + }) + + return pool + } + + const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { + const groupedFiles = new Map() + for (const { project, moduleId } of specs) { + const files = groupedFiles.get(project) || [] + files.push(moduleId) + groupedFiles.set(project, files) + } + + let isCancelled = false + vitest.onCancel(() => { + isCancelled = true + }) + + const initialisedPools = await Promise.all([...groupedFiles.entries()].map(async ([project, files]) => { + await project._initBrowserProvider() + + if (!project.browser) { + throw new TypeError(`The browser server was not initialized${project.name ? ` for the "${project.name}" project` : ''}. This is a bug in Vitest. Please, open a new issue with reproduction.`) + } + + if (isCancelled) { + return + } + + debug?.('provider is ready for %s project', project.name) + + const pool = ensurePool(project) + vitest.state.clearFiles(project, files) + providers.add(project.browser!.provider) + + return { + pool, + provider: project.browser!.provider, + runTests: () => pool.runTests(method, files), + } + })) + + if (isCancelled) { + return + } + + const parallelPools: (() => Promise)[] = [] + const nonParallelPools: (() => Promise)[] = [] + + for (const pool of initialisedPools) { + if (!pool) { + // this means it was cancelled + return + } + + if (pool.provider.mocker && pool.provider.supportsParallelism) { + parallelPools.push(pool.runTests) + } + else { + nonParallelPools.push(pool.runTests) + } + } + + await Promise.all(parallelPools.map(runTests => runTests())) + + for (const runTests of nonParallelPools) { + if (isCancelled) { + return + } + + await runTests() + } + } + + function getThreadsCount(project: TestProject) { + const config = project.config.browser + if ( + !config.headless + || !config.fileParallelism + || !project.browser!.provider.supportsParallelism + ) { + return 1 + } + + if (project.config.maxWorkers) { + return project.config.maxWorkers + } + + return threadsCount + } + + return { + name: 'browser', + async close() { + await Promise.all([...providers].map(provider => provider.close())) + vitest._browserSessions.sessionIds.clear() + providers.clear() + vitest.projects.forEach((project) => { + project.browser?.state.orchestrators.forEach((orchestrator) => { + orchestrator.$close() + }) + }) + debug?.('browser pool closed all providers') + }, + runTests: files => runWorkspaceTests('run', files), + collectTests: files => runWorkspaceTests('collect', files), + } +} + +function escapePathToRegexp(path: string): string { + return path.replace(/[/\\.?*()^${}|[\]+]/g, '\\$&') +} + +class BrowserPool { + private _queue: string[] = [] + private _promise: DeferPromise | undefined + private _providedContext: string | undefined + + private readySessions = new Set() + + constructor( + private project: TestProject, + private options: { + maxWorkers: number + origin: string + }, + ) {} + + public cancel(): void { + this._queue = [] + } + + public reject(error: Error): void { + this._promise?.reject(error) + this._promise = undefined + this.cancel() + } + + get orchestrators() { + return this.project.browser!.state.orchestrators + } + + async runTests(method: 'run' | 'collect', files: string[]): Promise { + this._promise ??= createDefer() + + if (!files.length) { + debug?.('no tests found, finishing test run immediately') + this._promise.resolve() + return this._promise + } + + this._providedContext = stringify(this.project.getProvidedContext()) + + this._queue.push(...files) + + this.readySessions.forEach((sessionId) => { + if (this._queue.length) { + this.readySessions.delete(sessionId) + this.runNextTest(method, sessionId) + } + }) + + if (this.orchestrators.size >= this.options.maxWorkers) { + debug?.('all orchestrators are ready, not creating more') + return this._promise + } + + // open the minimum amount of tabs + // if there is only 1 file running, we don't need 8 tabs running + const workerCount = Math.min( + this.options.maxWorkers - this.orchestrators.size, + files.length, + ) + + const promises: Promise[] = [] + for (let i = 0; i < workerCount; i++) { + const sessionId = crypto.randomUUID() + this.project.vitest._browserSessions.sessionIds.add(sessionId) + const project = this.project.name + debug?.('[%s] creating session for %s', sessionId, project) + const page = this.openPage(sessionId).then(() => { + // start running tests on the page when it's ready + this.runNextTest(method, sessionId) + }) + promises.push(page) + } + await Promise.all(promises) + debug?.('all sessions are created') + return this._promise + } + + private async openPage(sessionId: string) { + const sessionPromise = this.project.vitest._browserSessions.createSession( + sessionId, + this.project, + this, + ) + const browser = this.project.browser! + const url = new URL('/__vitest_test__/', this.options.origin) + url.searchParams.set('sessionId', sessionId) + const pagePromise = browser.provider.openPage( + sessionId, + url.toString(), + ) + await Promise.all([sessionPromise, pagePromise]) + } + + private getOrchestrator(sessionId: string) { + const orchestrator = this.orchestrators.get(sessionId) + if (!orchestrator) { + throw new Error(`Orchestrator not found for session ${sessionId}. This is a bug in Vitest. Please, open a new issue with reproduction.`) + } + return orchestrator + } + + private finishSession(sessionId: string): void { + this.readySessions.add(sessionId) + + // the last worker finished running tests + if (this.readySessions.size === this.orchestrators.size) { + this._promise?.resolve() + this._promise = undefined + debug?.('[%s] all tests finished running', sessionId) + } + else { + debug?.( + `did not finish sessions for ${sessionId}: |ready - %s| |overall - %s|`, + [...this.readySessions].join(', '), + [...this.orchestrators.keys()].join(', '), + ) + } + } + + private runNextTest(method: 'run' | 'collect', sessionId: string): void { + const file = this._queue.shift() + + if (!file) { + debug?.('[%s] no more tests to run', sessionId) + const isolate = this.project.config.browser.isolate + // we don't need to cleanup testers if isolation is enabled, + // because cleanup is done at the end of every test + if (isolate) { + this.finishSession(sessionId) + return + } + + // we need to cleanup testers first because there is only + // one iframe and it does the cleanup only after everything is completed + const orchestrator = this.getOrchestrator(sessionId) + orchestrator.cleanupTesters() + .catch(error => this.reject(error)) + .finally(() => this.finishSession(sessionId)) + return + } + + if (!this._promise) { + throw new Error(`Unexpected empty queue`) + } + const startTime = performance.now() + + const orchestrator = this.getOrchestrator(sessionId) + debug?.('[%s] run test %s', sessionId, file) + + this.setBreakpoint(sessionId, file).then(() => { + // this starts running tests inside the orchestrator + orchestrator.createTesters( + { + method, + files: [file], + // this will be parsed by the test iframe, not the orchestrator + // so we need to stringify it first to avoid double serialization + providedContext: this._providedContext || '[{}]', + startTime, + }, + ) + .then(() => { + debug?.('[%s] test %s finished running', sessionId, file) + this.runNextTest(method, sessionId) + }) + .catch((error) => { + // if user cancels the test run manually, ignore the error and exit gracefully + if ( + this.project.vitest.isCancelling + && error instanceof Error + && error.message.startsWith('Browser connection was closed while running tests') + ) { + this.cancel() + this._promise?.resolve() + this._promise = undefined + debug?.('[%s] browser connection was closed', sessionId) + return + } + debug?.('[%s] error during %s test run: %s', sessionId, file, error) + this.reject(error) + }) + }).catch(err => this.reject(err)) + } + + async setBreakpoint(sessionId: string, file: string) { + if (!this.project.config.inspector.waitForDebugger) { + return + } + + const provider = this.project.browser!.provider + const browser = this.project.config.browser.name + + if (shouldIgnoreDebugger(provider.name, browser)) { + debug?.('[$s] ignoring debugger in %s browser because it is not supported', sessionId, browser) + return + } + + if (!provider.getCDPSession) { + throw new Error('Unable to set breakpoint, CDP not supported') + } + + debug?.('[%s] set breakpoint for %s', sessionId, file) + const session = await provider.getCDPSession(sessionId) + await session.send('Debugger.enable', {}) + await session.send('Debugger.setBreakpointByUrl', { + lineNumber: 0, + urlRegex: escapePathToRegexp(file), + }) + } +} + +function shouldIgnoreDebugger(provider: string, browser: string) { + if (provider === 'webdriverio') { + return browser !== 'chrome' && browser !== 'edge' + } + return browser !== 'chromium' +} diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 840c4658dcd1..846f5f222798 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -453,18 +453,16 @@ export class TestProject { if (!this.isBrowserEnabled() || this._parentBrowser) { return } - if (typeof this.config.browser.provider!.serverFactory !== 'function') { - throw new TypeError(`The provider options do not return a "serverFactory" function.`) - } - const browser = await this.config.browser.provider!.serverFactory( - this, - this.vite.config.configFile, - [ - ...MocksPlugins(), // TODO: inject cacheDir inside the server factory - MetaEnvReplacerPlugin(), - ], - [CoverageTransform(this.vitest)], - ) + const provider = this.config.browser.provider! + if (typeof provider.serverFactory !== 'function') { + throw new TypeError(`The browser provider options do not return a "serverFactory" function. Are you using the latest "@vitest/browser-${provider?.name}" package?`) + } + const browser = await this.config.browser.provider!.serverFactory({ + project: this, + mocksPlugins: options => MocksPlugins(options), + metaEnvReplacer: () => MetaEnvReplacerPlugin(), + coveragePlugin: () => CoverageTransform(this.vitest), + }) this._parentBrowser = browser if (this.config.browser.ui) { setup(this.vitest, browser.vite) diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index f90ffdb7b978..441b7dcd0e38 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -26,12 +26,18 @@ export interface BrowserProviderOption { supportedBrowser?: ReadonlyArray options: Options providerFactory: (project: TestProject) => BrowserProvider - serverFactory: ( - project: TestProject, - configFile: string | undefined, - prePlugins: Plugin[], - postPlugins: Plugin[], - ) => Promise + serverFactory: BrowserServerFactory +} + +export interface BrowserServerOptions { + project: TestProject + coveragePlugin: () => Plugin + mocksPlugins: (options: { filter: (id: string) => boolean }) => Plugin[] + metaEnvReplacer: () => Plugin +} + +export interface BrowserServerFactory { + (otpions: BrowserServerOptions): Promise } export interface BrowserProvider { diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index 0f2cfc903884..ff8b3fe4de6c 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -69,6 +69,8 @@ export type { BrowserProvider, BrowserProviderOption, BrowserScript, + BrowserServerFactory, + BrowserServerOptions, BrowserServerState, BrowserServerStateSession, CDPSession, From ed6c594a2b40f45e31e07193173f0f28fa537608 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 11:07:16 +0200 Subject: [PATCH 04/43] feat: add wdio provider --- packages/browser-playwright/src/index.ts | 2 +- .../src/{provider.ts => playwright.ts} | 0 packages/browser-webdriverio/package.json | 52 +++ packages/browser-webdriverio/rollup.config.js | 62 ++++ packages/browser-webdriverio/src/index.ts | 5 + .../browser-webdriverio/src/webdriverio.ts | 296 ++++++++++++++++++ packages/browser-webdriverio/tsconfig.json | 13 + pnpm-lock.yaml | 19 ++ test/browser/package.json | 1 + test/browser/settings.ts | 2 +- test/browser/tsconfig.json | 1 - test/browser/vitest.config.mts | 2 +- tsconfig.base.json | 1 + 13 files changed, 452 insertions(+), 4 deletions(-) rename packages/browser-playwright/src/{provider.ts => playwright.ts} (100%) create mode 100644 packages/browser-webdriverio/package.json create mode 100644 packages/browser-webdriverio/rollup.config.js create mode 100644 packages/browser-webdriverio/src/index.ts create mode 100644 packages/browser-webdriverio/src/webdriverio.ts create mode 100644 packages/browser-webdriverio/tsconfig.json diff --git a/packages/browser-playwright/src/index.ts b/packages/browser-playwright/src/index.ts index 385b72c1693d..90757942d391 100644 --- a/packages/browser-playwright/src/index.ts +++ b/packages/browser-playwright/src/index.ts @@ -2,4 +2,4 @@ export { playwright, PlaywrightBrowserProvider, type PlaywrightProviderOptions, -} from './provider' +} from './playwright' diff --git a/packages/browser-playwright/src/provider.ts b/packages/browser-playwright/src/playwright.ts similarity index 100% rename from packages/browser-playwright/src/provider.ts rename to packages/browser-playwright/src/playwright.ts diff --git a/packages/browser-webdriverio/package.json b/packages/browser-webdriverio/package.json new file mode 100644 index 000000000000..e7b6fe56c984 --- /dev/null +++ b/packages/browser-webdriverio/package.json @@ -0,0 +1,52 @@ +{ + "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" + }, + "./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" + } +} diff --git a/packages/browser-webdriverio/rollup.config.js b/packages/browser-webdriverio/rollup.config.js new file mode 100644 index 000000000000..4a5ab941916b --- /dev/null +++ b/packages/browser-webdriverio/rollup.config.js @@ -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(\/|$)/, + '@vitest/browser/utils', + 'worker_threads', + 'node:worker_threads', + 'vite', + 'playwright-core/types/protocol', +] + +const dtsUtils = createDtsUtils() + +const plugins = [ + resolve({ + preferBuiltins: true, + }), + json(), + commonjs(), + oxc({ + transform: { target: 'node18' }, + }), +] + +export default () => + defineConfig([ + { + input: './src/index.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(), + }, + ]) diff --git a/packages/browser-webdriverio/src/index.ts b/packages/browser-webdriverio/src/index.ts new file mode 100644 index 000000000000..2980265fbaef --- /dev/null +++ b/packages/browser-webdriverio/src/index.ts @@ -0,0 +1,5 @@ +export { + WebdriverBrowserProvider, + webdriverio, + type WebdriverProviderOptions, +} from './webdriverio' diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts new file mode 100644 index 000000000000..70929a21943e --- /dev/null +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -0,0 +1,296 @@ +import type { + ScreenshotComparatorRegistry, + ScreenshotMatcherOptions, +} from '@vitest/browser/context' +import type { Capabilities } from '@wdio/types' +import type { + BrowserProvider, + BrowserProviderOption, + CDPSession, + TestProject, +} from 'vitest/node' +import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio' + +import { createDebugger } from 'vitest/node' + +const debug = createDebugger('vitest:browser:wdio') + +const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const +type WebdriverBrowser = (typeof webdriverBrowsers)[number] + +export interface WebdriverProviderOptions extends Partial< + Parameters[0] +> {} + +export function webdriverio(options: WebdriverProviderOptions = {}): BrowserProviderOption { + return { + name: 'webdriverio', + supportedBrowser: webdriverBrowsers, + options, + 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 { + public name = 'webdriverio' as const + public supportsParallelism: boolean = false + + public browser: WebdriverIO.Browser | null = null + + private browserName!: WebdriverBrowser + private project!: TestProject + + private options?: WebdriverProviderOptions + + private closing = false + private iframeSwitched = false + private topLevelContext: string | undefined + + getSupportedBrowsers(): readonly string[] { + return webdriverBrowsers + } + + constructor( + project: TestProject, + options: WebdriverProviderOptions, + ) { + // increase shutdown timeout because WDIO takes some extra time to kill the driver + if (!project.vitest.state._data.timeoutIncreased) { + project.vitest.state._data.timeoutIncreased = true + project.vitest.config.teardownTimeout += 10_000 + } + + this.closing = false + this.project = project + this.browserName = project.config.browser.name as WebdriverBrowser + this.options = options + } + + isIframeSwitched(): boolean { + return this.iframeSwitched + } + + async switchToTestFrame(): Promise { + const browser = this.browser! + // support wdio@9 + if (browser.switchFrame) { + await browser.switchFrame(browser.$('iframe[data-vitest]')) + } + else { + const iframe = await browser.findElement( + 'css selector', + 'iframe[data-vitest]', + ) + await browser.switchToFrame(iframe) + } + this.iframeSwitched = true + } + + async switchToMainFrame(): Promise { + const page = this.browser! + if (page.switchFrame) { + await page.switchFrame(null) + } + else { + await page.switchToParentFrame() + } + this.iframeSwitched = false + } + + async setViewport(options: { width: number; height: number }): Promise { + if (this.topLevelContext == null || !this.browser) { + throw new Error(`The browser has no open pages.`) + } + await this.browser.send({ + method: 'browsingContext.setViewport', + params: { + context: this.topLevelContext, + devicePixelRatio: 1, + viewport: options, + }, + }) + } + + getCommandsContext(): { + browser: WebdriverIO.Browser | null + } { + return { + browser: this.browser, + } + } + + async openBrowser(): Promise { + await this._throwIfClosing('opening the browser') + + if (this.browser) { + debug?.('[%s] the browser is already opened, reusing it', this.browserName) + return this.browser + } + + const options = this.project.config.browser + + if (this.browserName === 'safari') { + if (options.headless) { + throw new Error( + 'You\'ve enabled headless mode for Safari but it doesn\'t currently support it.', + ) + } + } + + const { remote } = await import('webdriverio') + + const remoteOptions: Capabilities.WebdriverIOConfig = { + logLevel: 'silent', + ...this.options, + capabilities: this.buildCapabilities(), + } + + debug?.('[%s] opening the browser with options: %O', this.browserName, remoteOptions) + // TODO: close everything, if browser is closed from the outside + this.browser = await remote(remoteOptions) + await this._throwIfClosing() + + return this.browser + } + + private buildCapabilities() { + const capabilities: Capabilities.WebdriverIOConfig['capabilities'] = { + ...this.options?.capabilities, + browserName: this.browserName, + } + + const headlessMap = { + chrome: ['goog:chromeOptions', ['headless', 'disable-gpu']], + firefox: ['moz:firefoxOptions', ['-headless']], + edge: ['ms:edgeOptions', ['--headless']], + } as const + + const options = this.project.config.browser + const browser = this.browserName + if (browser !== 'safari' && options.headless) { + const [key, args] = headlessMap[browser] + const currentValues = (this.options?.capabilities as any)?.[key] || {} + const newArgs = [...(currentValues.args || []), ...args] + capabilities[key] = { ...currentValues, args: newArgs as any } + } + + // start Vitest UI maximized only on supported browsers + if (options.ui && (browser === 'chrome' || browser === 'edge')) { + const key = browser === 'chrome' + ? 'goog:chromeOptions' + : 'ms:edgeOptions' + const args = capabilities[key]?.args || [] + if (!args.includes('--start-maximized') && !args.includes('--start-fullscreen')) { + args.push('--start-maximized') + } + capabilities[key] ??= {} + capabilities[key]!.args = args + } + + const inspector = this.project.vitest.config.inspector + if (inspector.enabled && (browser === 'chrome' || browser === 'edge')) { + const key = browser === 'chrome' + ? 'goog:chromeOptions' + : 'ms:edgeOptions' + const args = capabilities[key]?.args || [] + + // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector + const port = inspector.port || 9229 + const host = inspector.host || '127.0.0.1' + + args.push(`--remote-debugging-port=${port}`) + args.push(`--remote-debugging-address=${host}`) + + this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`) + + capabilities[key] ??= {} + capabilities[key]!.args = args + } + + return capabilities + } + + async openPage(sessionId: string, url: string): Promise { + await this._throwIfClosing('creating the browser') + debug?.('[%s][%s] creating the browser page for %s', sessionId, this.browserName, url) + const browserInstance = await this.openBrowser() + debug?.('[%s][%s] browser page is created, opening %s', sessionId, this.browserName, url) + await browserInstance.url(url) + this.topLevelContext = await browserInstance.getWindowHandle() + await this._throwIfClosing('opening the url') + } + + private async _throwIfClosing(action?: string) { + if (this.closing) { + debug?.(`[%s] provider was closed, cannot perform the action${action ? ` ${action}` : ''}`, this.browserName) + await (this.browser?.sessionId ? this.browser?.deleteSession?.() : null) + throw new Error(`[vitest] The provider was closed.`) + } + } + + async close(): Promise { + debug?.('[%s] closing provider', this.browserName) + this.closing = true + const browser = this.browser + const sessionId = browser?.sessionId + if (!browser || !sessionId) { + return + } + + // https://github.com/webdriverio/webdriverio/blob/ab1a2e82b13a9c7d0e275ae87e7357e1b047d8d3/packages/wdio-runner/src/index.ts#L486 + await browser.deleteSession() + browser.sessionId = undefined as unknown as string + this.browser = null + } + + async getCDPSession(_sessionId: string): Promise { + return { + send: (method: string, params: any) => { + if (!this.browser) { + throw new Error(`The environment was torn down.`) + } + return this.browser.sendCommandAndGetResult(method, params ?? {}).catch((error) => { + return Promise.reject(new Error(`Failed to execute "${method}" command.`, { cause: error })) + }) + }, + on: () => { + throw new Error(`webdriverio provider doesn't support cdp.on()`) + }, + once: () => { + throw new Error(`webdriverio provider doesn't support cdp.once()`) + }, + off: () => { + throw new Error(`webdriverio provider doesn't support cdp.off()`) + }, + } + } +} + +declare module 'vitest/node' { + export interface UserEventClickOptions extends ClickOptions {} + + export interface UserEventDragOptions extends DragAndDropOptions { + sourceX?: number + sourceY?: number + targetX?: number + targetY?: number + } + + export interface BrowserCommandContext { + browser: WebdriverIO.Browser + } + + export interface ToMatchScreenshotOptions + extends Omit< + ScreenshotMatcherOptions, + 'comparatorName' | 'comparatorOptions' + > {} + + export interface ToMatchScreenshotComparators + extends ScreenshotComparatorRegistry {} +} diff --git a/packages/browser-webdriverio/tsconfig.json b/packages/browser-webdriverio/tsconfig.json new file mode 100644 index 000000000000..89c57bbd409f --- /dev/null +++ b/packages/browser-webdriverio/tsconfig.json @@ -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" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f082329befdc..62cdf2a8d4b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,6 +531,22 @@ importers: specifier: workspace:* version: link:../vitest + packages/browser-webdriverio: + dependencies: + '@vitest/browser': + specifier: workspace:* + version: link:../browser + devDependencies: + '@wdio/types': + specifier: ^9.19.2 + version: 9.19.2 + vitest: + specifier: workspace:* + version: link:../vitest + webdriverio: + specifier: ^9.19.2 + version: 9.19.2 + packages/coverage-istanbul: dependencies: '@istanbuljs/schema': @@ -1126,6 +1142,9 @@ importers: '@vitest/browser': specifier: workspace:* version: link:../../packages/browser + '@vitest/browser-playwright': + specifier: workspace:* + version: link:../../packages/browser-playwright '@vitest/bundled-lib': specifier: link:./bundled-lib version: link:bundled-lib diff --git a/test/browser/package.json b/test/browser/package.json index 114b02163666..5e5ef6364f44 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -33,6 +33,7 @@ "@types/react": "^19.1.13", "@vitejs/plugin-basic-ssl": "^2.1.0", "@vitest/browser": "workspace:*", + "@vitest/browser-playwright": "workspace:*", "@vitest/bundled-lib": "link:./bundled-lib", "@vitest/cjs-lib": "link:./cjs-lib", "playwright": "^1.55.0", diff --git a/test/browser/settings.ts b/test/browser/settings.ts index 3da09eed68b3..bb3b1754ed8d 100644 --- a/test/browser/settings.ts +++ b/test/browser/settings.ts @@ -1,5 +1,5 @@ import type { BrowserInstanceOption } from 'vitest/node' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { preview } from '@vitest/browser/providers/preview' import { webdriverio } from '@vitest/browser/providers/webdriverio' diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json index 7c0b0f12cfbd..dc59446e1fd5 100644 --- a/test/browser/tsconfig.json +++ b/test/browser/tsconfig.json @@ -9,7 +9,6 @@ }, "types": [ "vite/client", - "@vitest/browser/providers/playwright", "vitest-browser-react", "vitest/import-meta" ], diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index bb01b21751cb..ea9f169c02d2 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -2,7 +2,7 @@ import type { BrowserCommand, BrowserInstanceOption } from 'vitest/node' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import * as util from 'node:util' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { preview } from '@vitest/browser/providers/preview' import { webdriverio } from '@vitest/browser/providers/webdriverio' import { defineConfig } from 'vitest/config' diff --git a/tsconfig.base.json b/tsconfig.base.json index da5ecf972430..b127f95f647c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,6 +19,7 @@ "@vitest/mocker/browser": ["./packages/mocker/src/browser/index.ts"], "@vitest/runner": ["./packages/runner/src/index.ts"], "@vitest/runner/*": ["./packages/runner/src/*"], + "@vitest/browser-playwright": ["./packages/browser-playwright/src/index.ts"], "@vitest/browser": ["./packages/browser/src/node/index.ts"], "@vitest/browser/client": ["./packages/browser/src/client/client.ts"], "~/*": ["./packages/ui/client/*"], From c577be9807ac6d872c7c743452bad3aa9f14eb05 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 11:10:18 +0200 Subject: [PATCH 05/43] feat: add preview package --- packages/browser-preview/package.json | 46 ++++++++++++++ packages/browser-preview/rollup.config.js | 62 +++++++++++++++++++ packages/browser-preview/src/index.ts | 4 ++ packages/browser-preview/src/preview.ts | 53 ++++++++++++++++ packages/browser-preview/tsconfig.json | 13 ++++ .../browser-webdriverio/src/webdriverio.ts | 5 +- 6 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 packages/browser-preview/package.json create mode 100644 packages/browser-preview/rollup.config.js create mode 100644 packages/browser-preview/src/index.ts create mode 100644 packages/browser-preview/src/preview.ts create mode 100644 packages/browser-preview/tsconfig.json diff --git a/packages/browser-preview/package.json b/packages/browser-preview/package.json new file mode 100644 index 000000000000..eee3fec8a839 --- /dev/null +++ b/packages/browser-preview/package.json @@ -0,0 +1,46 @@ +{ + "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" + }, + "./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:*" + } +} diff --git a/packages/browser-preview/rollup.config.js b/packages/browser-preview/rollup.config.js new file mode 100644 index 000000000000..4a5ab941916b --- /dev/null +++ b/packages/browser-preview/rollup.config.js @@ -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(\/|$)/, + '@vitest/browser/utils', + 'worker_threads', + 'node:worker_threads', + 'vite', + 'playwright-core/types/protocol', +] + +const dtsUtils = createDtsUtils() + +const plugins = [ + resolve({ + preferBuiltins: true, + }), + json(), + commonjs(), + oxc({ + transform: { target: 'node18' }, + }), +] + +export default () => + defineConfig([ + { + input: './src/index.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(), + }, + ]) diff --git a/packages/browser-preview/src/index.ts b/packages/browser-preview/src/index.ts new file mode 100644 index 000000000000..0ba63aaa9fda --- /dev/null +++ b/packages/browser-preview/src/index.ts @@ -0,0 +1,4 @@ +export { + preview, + PreviewBrowserProvider, +} from './preview' diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts new file mode 100644 index 000000000000..5956f4906240 --- /dev/null +++ b/packages/browser-preview/src/preview.ts @@ -0,0 +1,53 @@ +import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node' +import { createBrowserServer } from '@vitest/browser' + +export function preview(): BrowserProviderOption { + return { + name: 'preview', + options: {}, + providerFactory(project) { + return new PreviewBrowserProvider(project) + }, + serverFactory: createBrowserServer, + } +} + +export class PreviewBrowserProvider implements BrowserProvider { + public name = 'preview' as const + public supportsParallelism: boolean = false + private project!: TestProject + private open = false + + constructor(project: TestProject) { + this.project = project + this.open = false + if (project.config.browser.headless) { + throw new Error( + '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) + } + + isOpen(): boolean { + return this.open + } + + getCommandsContext() { + return {} + } + + async openPage(_sessionId: string, url: string): Promise { + this.open = true + if (!this.project.browser) { + throw new Error('Browser is not initialized') + } + const options = this.project.browser.vite.config.server + const _open = options.open + options.open = url + this.project.browser.vite.openBrowser() + options.open = _open + } + + async close(): Promise {} +} diff --git a/packages/browser-preview/tsconfig.json b/packages/browser-preview/tsconfig.json new file mode 100644 index 000000000000..89c57bbd409f --- /dev/null +++ b/packages/browser-preview/tsconfig.json @@ -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" + ] +} diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 70929a21943e..bc01107d6a93 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -10,6 +10,7 @@ import type { TestProject, } from 'vitest/node' import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio' +import { createBrowserServer } from '@vitest/browser' import { createDebugger } from 'vitest/node' @@ -30,9 +31,7 @@ export function webdriverio(options: WebdriverProviderOptions = {}): BrowserProv providerFactory(project) { return new WebdriverBrowserProvider(project, options) }, - // --browser.provider=webdriverio - // @ts-expect-error hidden way to bypass importing webdriverio - _cli: true, + serverFactory: createBrowserServer, } } From e36df43d981beedbee4be0f912fbbbd6fbf7cec1 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 11:22:03 +0200 Subject: [PATCH 06/43] fix: update imports --- examples/lit/package.json | 2 +- examples/lit/tsconfig.json | 1 - examples/lit/vite.config.ts | 2 +- package.json | 3 + packages/browser/package.json | 4 - packages/browser/providers.d.ts | 7 - packages/browser/rollup.config.js | 6 +- packages/browser/src/node/providers/index.ts | 3 - .../browser/src/node/providers/playwright.ts | 592 ------------------ .../browser/src/node/providers/preview.ts | 54 -- .../browser/src/node/providers/webdriverio.ts | 296 --------- packages/coverage-v8/tsconfig.json | 1 - packages/ui/package.json | 1 + packages/ui/vitest.config.ts | 2 +- packages/vitest/src/create/browser/creator.ts | 8 +- .../vitest/src/node/config/resolveConfig.ts | 2 +- pnpm-lock.yaml | 63 +- .../fixtures/trace-view/vitest.config.ts | 2 +- test/browser/package.json | 2 + test/browser/settings.ts | 4 +- test/browser/specs/playwright-connect.test.ts | 2 +- .../browser/specs/to-match-screenshot.test.ts | 4 +- test/browser/vitest.config.mts | 4 +- .../browser-multiple/vitest.config.ts | 2 +- .../config-loader/browser/vitest.config.ts | 2 +- test/cli/fixtures/list/vitest.config.ts | 2 +- test/cli/fixtures/public-api/vitest.config.ts | 2 +- test/cli/package.json | 1 + test/cli/test/annotations.test.ts | 2 +- test/cli/test/fails.test.ts | 2 +- test/cli/test/init.test.ts | 2 +- test/cli/test/scoped-fixtures.test.ts | 2 +- .../vitest.config.correct.ts | 2 +- ...vitest.config.custom-transformIndexHtml.ts | 2 +- ...itest.config.default-transformIndexHtml.ts | 2 +- .../vitest.config.error-hook.ts | 2 +- .../vitest.config.non-existing.ts | 2 +- test/config/package.json | 3 + test/config/test/bail.test.ts | 2 +- test/config/test/browser-configs.test.ts | 6 +- test/config/test/failures.test.ts | 6 +- test/coverage-test/package.json | 2 +- test/coverage-test/utils.ts | 2 +- test/dts-playwright/package.json | 2 +- test/dts-playwright/tsconfig.json | 1 - test/dts-playwright/vite.config.ts | 2 +- test/watch/package.json | 3 +- test/watch/test/config-watching.test.ts | 2 +- test/watch/test/file-watching.test.ts | 2 +- test/workspaces-browser/package.json | 2 +- .../space_browser/vitest.config.ts | 2 +- test/workspaces-browser/vitest.config.ts | 2 +- 52 files changed, 111 insertions(+), 1020 deletions(-) delete mode 100644 packages/browser/providers.d.ts delete mode 100644 packages/browser/src/node/providers/index.ts delete mode 100644 packages/browser/src/node/providers/playwright.ts delete mode 100644 packages/browser/src/node/providers/preview.ts delete mode 100644 packages/browser/src/node/providers/webdriverio.ts diff --git a/examples/lit/package.json b/examples/lit/package.json index 7d684e6e5bc6..019e3e080b59 100644 --- a/examples/lit/package.json +++ b/examples/lit/package.json @@ -17,7 +17,7 @@ "lit": "^3.3.1" }, "devDependencies": { - "@vitest/browser": "latest", + "@vitest/browser-playwright": "latest", "jsdom": "latest", "playwright": "^1.55.0", "vite": "latest", diff --git a/examples/lit/tsconfig.json b/examples/lit/tsconfig.json index 51f02ea20c1f..8f53fa03471a 100644 --- a/examples/lit/tsconfig.json +++ b/examples/lit/tsconfig.json @@ -5,7 +5,6 @@ "experimentalDecorators": true, "module": "node16", "moduleResolution": "Node16", - "types": ["@vitest/browser/providers/playwright"], "verbatimModuleSyntax": true } } diff --git a/examples/lit/vite.config.ts b/examples/lit/vite.config.ts index d4a0022e4b28..4c6060526bec 100644 --- a/examples/lit/vite.config.ts +++ b/examples/lit/vite.config.ts @@ -1,6 +1,6 @@ /// -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vite' // https://vitejs.dev/config/ diff --git a/package.json b/package.json index 4d6d1304ef1e..28a699f75548 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/browser/package.json b/packages/browser/package.json index 57c03ddcf674..ba9aa58c2f86 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -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" diff --git a/packages/browser/providers.d.ts b/packages/browser/providers.d.ts deleted file mode 100644 index 1106f2d6db06..000000000000 --- a/packages/browser/providers.d.ts +++ /dev/null @@ -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 } diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 6b56b70341aa..780c28294aca 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -17,7 +17,6 @@ const external = [ 'worker_threads', 'node:worker_threads', 'vite', - 'playwright-core/types/protocol', ] const dtsUtils = createDtsUtils() @@ -39,10 +38,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 () => diff --git a/packages/browser/src/node/providers/index.ts b/packages/browser/src/node/providers/index.ts deleted file mode 100644 index a75c6cb36a28..000000000000 --- a/packages/browser/src/node/providers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { playwright } from './playwright' -export { preview } from './preview' -export { webdriverio } from './webdriverio' diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts deleted file mode 100644 index 95025fbbbc3b..000000000000 --- a/packages/browser/src/node/providers/playwright.ts +++ /dev/null @@ -1,592 +0,0 @@ -/* eslint-disable ts/method-signature-style */ - -import type { - ScreenshotComparatorRegistry, - ScreenshotMatcherOptions, -} from '@vitest/browser/context' -import type { MockedModule } from '@vitest/mocker' -import type { - Browser, - BrowserContext, - BrowserContextOptions, - ConnectOptions, - Frame, - FrameLocator, - LaunchOptions, - Page, -} from 'playwright' -import type { Protocol } from 'playwright-core/types/protocol' -import type { SourceMap } from 'rollup' -import type { ResolvedConfig } from 'vite' -import type { - BrowserModuleMocker, - BrowserProvider, - BrowserProviderOption, - CDPSession, - TestProject, -} from 'vitest/node' -import { createManualModuleSource } from '@vitest/mocker/node' -import c from 'tinyrainbow' -import { createDebugger, isCSSRequest } from 'vitest/node' - -const debug = createDebugger('vitest:browser:playwright') - -const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const -type PlaywrightBrowser = (typeof playwrightBrowsers)[number] - -export interface PlaywrightProviderOptions { - /** - * The options passed down to [`playwright.connect`](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) method. - * @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-launch} - */ - launchOptions?: Omit< - LaunchOptions, - 'tracesDir' - > - /** - * The options passed down to [`playwright.connect`](https://playwright.dev/docs/api/class-browsertype#browser-type-connect) method. - * - * This is used only if you connect remotely to the playwright instance via a WebSocket connection. - * @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-connect} - */ - connectOptions?: ConnectOptions & { - wsEndpoint: string - } - /** - * The options passed down to [`browser.newContext`](https://playwright.dev/docs/api/class-browser#browser-new-context) method. - * @see {@link https://playwright.dev/docs/api/class-browser#browser-new-context} - */ - contextOptions?: Omit< - BrowserContextOptions, - 'ignoreHTTPSErrors' | 'serviceWorkers' - > - /** - * The maximum time in milliseconds to wait for `userEvent` action to complete. - * @default 0 (no timeout) - */ - actionTimeout?: number -} - -export function playwright(options: PlaywrightProviderOptions = {}): BrowserProviderOption { - return { - name: 'playwright', - supportedBrowser: playwrightBrowsers, - options, - 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 { - public name = 'playwright' as const - public supportsParallelism = true - - public browser: Browser | null = null - - public contexts: Map = new Map() - public pages: Map = new Map() - public mocker: BrowserModuleMocker - public browserName: PlaywrightBrowser - - private browserPromise: Promise | null = null - private closing = false - - public tracingContexts: Set = new Set() - public pendingTraces: Map = new Map() - - constructor( - private project: TestProject, - private options: PlaywrightProviderOptions, - ) { - this.browserName = project.config.browser.name as PlaywrightBrowser - this.mocker = this.createMocker() - - // make sure the traces are finished if the test hangs - process.on('SIGTERM', () => { - if (!this.browser) { - return - } - const promises = [] - for (const [trace, contextId] of this.pendingTraces.entries()) { - promises.push((() => { - const context = this.contexts.get(contextId) - return context?.tracing.stopChunk({ path: trace }) - })()) - } - return Promise.allSettled(promises) - }) - } - - private async openBrowser() { - await this._throwIfClosing() - - if (this.browserPromise) { - debug?.('[%s] the browser is resolving, reusing the promise', this.browserName) - return this.browserPromise - } - - if (this.browser) { - debug?.('[%s] the browser is resolved, reusing it', this.browserName) - return this.browser - } - - this.browserPromise = (async () => { - const options = this.project.config.browser - - const playwright = await import('playwright') - - if (this.options.connectOptions) { - if (this.options.launchOptions) { - this.project.vitest.logger.warn( - c.yellow(`Found both ${c.bold(c.italic(c.yellow('connect')))} and ${c.bold(c.italic(c.yellow('launch')))} options in browser instance configuration. - Ignoring ${c.bold(c.italic(c.yellow('launch')))} options and using ${c.bold(c.italic(c.yellow('connect')))} mode. - You probably want to remove one of the two options and keep only the one you want to use.`), - ) - } - const browser = await playwright[this.browserName].connect(this.options.connectOptions.wsEndpoint, this.options.connectOptions) - this.browser = browser - this.browserPromise = null - return this.browser - } - - const launchOptions: LaunchOptions = { - ...this.options.launchOptions, - headless: options.headless, - } - - if (typeof options.trace === 'object' && options.trace.tracesDir) { - launchOptions.tracesDir = options.trace?.tracesDir - } - - const inspector = this.project.vitest.config.inspector - if (inspector.enabled) { - // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector - const port = inspector.port || 9229 - const host = inspector.host || '127.0.0.1' - - launchOptions.args ||= [] - launchOptions.args.push(`--remote-debugging-port=${port}`) - launchOptions.args.push(`--remote-debugging-address=${host}`) - - this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`) - } - - // start Vitest UI maximized only on supported browsers - if (this.project.config.browser.ui && this.browserName === 'chromium') { - if (!launchOptions.args) { - launchOptions.args = [] - } - if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) { - launchOptions.args.push('--start-maximized') - } - } - - debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions) - this.browser = await playwright[this.browserName].launch(launchOptions) - this.browserPromise = null - return this.browser - })() - - return this.browserPromise - } - - private createMocker(): BrowserModuleMocker { - const idPreficates = new Map boolean>() - const sessionIds = new Map() - - function createPredicate(sessionId: string, url: string) { - const moduleUrl = new URL(url, 'http://localhost') - const predicate = (url: URL) => { - if (url.searchParams.has('_vitest_original')) { - return false - } - - // different modules, ignore request - if (url.pathname !== moduleUrl.pathname) { - return false - } - - url.searchParams.delete('t') - url.searchParams.delete('v') - url.searchParams.delete('import') - - // different search params, ignore request - if (url.searchParams.size !== moduleUrl.searchParams.size) { - return false - } - - // check that all search params are the same - for (const [param, value] of url.searchParams.entries()) { - if (moduleUrl.searchParams.get(param) !== value) { - return false - } - } - - return true - } - const ids = sessionIds.get(sessionId) || [] - ids.push(moduleUrl.href) - sessionIds.set(sessionId, ids) - idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate) - return predicate - } - - function predicateKey(sessionId: string, url: string) { - return `${sessionId}:${url}` - } - - return { - register: async (sessionId: string, module: MockedModule): Promise => { - const page = this.getPage(sessionId) - await page.route(createPredicate(sessionId, module.url), async (route) => { - if (module.type === 'manual') { - const exports = Object.keys(await module.resolve()) - const body = createManualModuleSource(module.url, exports) - return route.fulfill({ - body, - headers: getHeaders(this.project.browser!.vite.config), - }) - } - - // webkit doesn't support redirect responses - // https://github.com/microsoft/playwright/issues/18318 - const isWebkit = this.browserName === 'webkit' - if (isWebkit) { - let url: string - if (module.type === 'redirect') { - const redirect = new URL(module.redirect) - url = redirect.href.slice(redirect.origin.length) - } - else { - const request = new URL(route.request().url()) - request.searchParams.set('mock', module.type) - url = request.href.slice(request.origin.length) - } - - const result = await this.project.browser!.vite.transformRequest(url).catch(() => null) - if (!result) { - return route.continue() - } - let content = result.code - if (result.map && 'version' in result.map && result.map.mappings) { - const type = isDirectCSSRequest(url) ? 'css' : 'js' - content = getCodeWithSourcemap(type, content.toString(), result.map) - } - return route.fulfill({ - body: content, - headers: getHeaders(this.project.browser!.vite.config), - }) - } - - if (module.type === 'redirect') { - return route.fulfill({ - status: 302, - headers: { - Location: module.redirect, - }, - }) - } - else if (module.type === 'automock' || module.type === 'autospy') { - const url = new URL(route.request().url()) - url.searchParams.set('mock', module.type) - return route.fulfill({ - status: 302, - headers: { - Location: url.href, - }, - }) - } - else { - // all types are exhausted - const _module: never = module - } - }) - }, - delete: async (sessionId: string, id: string): Promise => { - const page = this.getPage(sessionId) - const key = predicateKey(sessionId, id) - const predicate = idPreficates.get(key) - if (predicate) { - await page.unroute(predicate).finally(() => idPreficates.delete(key)) - } - }, - clear: async (sessionId: string): Promise => { - const page = this.getPage(sessionId) - const ids = sessionIds.get(sessionId) || [] - const promises = ids.map((id) => { - const key = predicateKey(sessionId, id) - const predicate = idPreficates.get(key) - if (predicate) { - return page.unroute(predicate).finally(() => idPreficates.delete(key)) - } - return null - }) - await Promise.all(promises).finally(() => sessionIds.delete(sessionId)) - }, - } - } - - private async createContext(sessionId: string) { - await this._throwIfClosing() - - if (this.contexts.has(sessionId)) { - debug?.('[%s][%s] the context already exists, reusing it', sessionId, this.browserName) - return this.contexts.get(sessionId)! - } - - const browser = await this.openBrowser() - await this._throwIfClosing(browser) - const actionTimeout = this.options.actionTimeout - const contextOptions = this.options.contextOptions ?? {} - const options = { - ...contextOptions, - ignoreHTTPSErrors: true, - } satisfies BrowserContextOptions - if (this.project.config.browser.ui) { - options.viewport = null - } - const context = await browser.newContext(options) - await this._throwIfClosing(context) - if (actionTimeout != null) { - context.setDefaultTimeout(actionTimeout) - } - debug?.('[%s][%s] the context is ready', sessionId, this.browserName) - this.contexts.set(sessionId, context) - return context - } - - public getPage(sessionId: string): Page { - const page = this.pages.get(sessionId) - if (!page) { - throw new Error(`Page "${sessionId}" not found in ${this.browserName} browser.`) - } - return page - } - - public getCommandsContext(sessionId: string): { - page: Page - context: BrowserContext - frame: () => Promise - readonly iframe: FrameLocator - } { - const page = this.getPage(sessionId) - return { - page, - context: this.contexts.get(sessionId)!, - frame(): Promise { - return new Promise((resolve, reject) => { - const frame = page.frame('vitest-iframe') - if (frame) { - return resolve(frame) - } - - const timeout = setTimeout(() => { - const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`) - reject(err) - }, 1000).unref() - page.on('frameattached', (frame) => { - clearTimeout(timeout) - resolve(frame) - }) - }) - }, - get iframe(): FrameLocator { - return page.frameLocator('[data-vitest="true"]')! - }, - } - } - - private async openBrowserPage(sessionId: string) { - await this._throwIfClosing() - - if (this.pages.has(sessionId)) { - debug?.('[%s][%s] the page already exists, closing the old one', sessionId, this.browserName) - const page = this.pages.get(sessionId)! - await page.close() - this.pages.delete(sessionId) - } - - const context = await this.createContext(sessionId) - const page = await context.newPage() - debug?.('[%s][%s] the page is ready', sessionId, this.browserName) - await this._throwIfClosing(page) - this.pages.set(sessionId, page) - - if (process.env.VITEST_PW_DEBUG) { - page.on('requestfailed', (request) => { - console.error( - '[PW Error]', - request.resourceType(), - 'request failed for', - request.url(), - 'url:', - request.failure()?.errorText, - ) - }) - } - - return page - } - - async openPage(sessionId: string, url: string, beforeNavigate?: () => Promise): Promise { - 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) - } - - private async _throwIfClosing(disposable?: { close: () => Promise }) { - if (this.closing) { - debug?.('[%s] provider was closed, cannot perform the action on %s', this.browserName, String(disposable)) - await disposable?.close() - this.pages.clear() - this.contexts.clear() - this.browser = null - this.browserPromise = null - throw new Error(`[vitest] The provider was closed.`) - } - } - - async getCDPSession(sessionid: string): Promise { - const page = this.getPage(sessionid) - const cdp = await page.context().newCDPSession(page) - return { - async send(method: string, params: any) { - const result = await cdp.send(method as 'DOM.querySelector', params) - return result as unknown - }, - on(event: string, listener: (...args: any[]) => void) { - cdp.on(event as 'Accessibility.loadComplete', listener) - }, - off(event: string, listener: (...args: any[]) => void) { - cdp.off(event as 'Accessibility.loadComplete', listener) - }, - once(event: string, listener: (...args: any[]) => void) { - cdp.once(event as 'Accessibility.loadComplete', listener) - }, - } - } - - async close(): Promise { - debug?.('[%s] closing provider', this.browserName) - this.closing = true - if (this.browserPromise) { - await this.browserPromise - this.browserPromise = null - } - const browser = this.browser - this.browser = null - await Promise.all([...this.pages.values()].map(p => p.close())) - this.pages.clear() - await Promise.all([...this.contexts.values()].map(c => c.close())) - this.contexts.clear() - await browser?.close() - debug?.('[%s] provider is closed', this.browserName) - } -} - -function getHeaders(config: ResolvedConfig) { - const headers: Record = { - 'Content-Type': 'application/javascript', - } - - for (const name in config.server.headers) { - headers[name] = String(config.server.headers[name]!) - } - return headers -} - -function getCodeWithSourcemap( - type: 'js' | 'css', - code: string, - map: SourceMap, -): string { - if (type === 'js') { - code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}` - } - else if (type === 'css') { - code += `\n/*# sourceMappingURL=${genSourceMapUrl(map)} */` - } - - return code -} - -function genSourceMapUrl(map: SourceMap | string): string { - if (typeof map !== 'string') { - map = JSON.stringify(map) - } - return `data:application/json;base64,${Buffer.from(map).toString('base64')}` -} - -const directRequestRE = /[?&]direct\b/ - -function isDirectCSSRequest(request: string): boolean { - return isCSSRequest(request) && directRequestRE.test(request) -} - -declare module 'vitest/node' { - export interface BrowserCommandContext { - page: Page - frame(): Promise - iframe: FrameLocator - context: BrowserContext - } - - export interface ToMatchScreenshotOptions - extends Omit< - ScreenshotMatcherOptions, - 'comparatorName' | 'comparatorOptions' - > {} - - export interface ToMatchScreenshotComparators - extends ScreenshotComparatorRegistry {} -} - -type PWHoverOptions = NonNullable[1]> -type PWClickOptions = NonNullable[1]> -type PWDoubleClickOptions = NonNullable[1]> -type PWFillOptions = NonNullable[2]> -type PWScreenshotOptions = NonNullable[0]> -type PWSelectOptions = NonNullable[2]> -type PWDragAndDropOptions = NonNullable[2]> -type PWSetInputFiles = NonNullable[2]> - -declare module '@vitest/browser/context' { - export interface UserEventHoverOptions extends PWHoverOptions {} - export interface UserEventClickOptions extends PWClickOptions {} - export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {} - export interface UserEventTripleClickOptions extends PWClickOptions {} - export interface UserEventFillOptions extends PWFillOptions {} - export interface UserEventSelectOptions extends PWSelectOptions {} - export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {} - export interface UserEventUploadOptions extends PWSetInputFiles {} - - export interface ScreenshotOptions extends Omit { - mask?: ReadonlyArray | undefined - } - - export interface CDPSession { - send( - method: T, - params?: Protocol.CommandParameters[T] - ): Promise - on( - event: T, - listener: (payload: Protocol.Events[T]) => void - ): this - once( - event: T, - listener: (payload: Protocol.Events[T]) => void - ): this - off( - event: T, - listener: (payload: Protocol.Events[T]) => void - ): this - } -} diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts deleted file mode 100644 index 7e648122c87f..000000000000 --- a/packages/browser/src/node/providers/preview.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node' - -export function preview(): BrowserProviderOption { - return { - name: 'preview', - options: {}, - 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 { - public name = 'preview' as const - public supportsParallelism: boolean = false - private project!: TestProject - private open = false - - constructor(project: TestProject) { - this.project = project - this.open = false - if (project.config.browser.headless) { - throw new Error( - '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) - } - - isOpen(): boolean { - return this.open - } - - getCommandsContext() { - return {} - } - - async openPage(_sessionId: string, url: string): Promise { - this.open = true - if (!this.project.browser) { - throw new Error('Browser is not initialized') - } - const options = this.project.browser.vite.config.server - const _open = options.open - options.open = url - this.project.browser.vite.openBrowser() - options.open = _open - } - - async close(): Promise {} -} diff --git a/packages/browser/src/node/providers/webdriverio.ts b/packages/browser/src/node/providers/webdriverio.ts deleted file mode 100644 index c5b985348231..000000000000 --- a/packages/browser/src/node/providers/webdriverio.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { - ScreenshotComparatorRegistry, - ScreenshotMatcherOptions, -} from '@vitest/browser/context' -import type { Capabilities } from '@wdio/types' -import type { - BrowserProvider, - BrowserProviderOption, - CDPSession, - TestProject, -} from 'vitest/node' -import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio' - -import { createDebugger } from 'vitest/node' - -const debug = createDebugger('vitest:browser:wdio') - -const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const -type WebdriverBrowser = (typeof webdriverBrowsers)[number] - -interface WebdriverProviderOptions extends Partial< - Parameters[0] -> {} - -export function webdriverio(options: WebdriverProviderOptions = {}): BrowserProviderOption { - return { - name: 'webdriverio', - supportedBrowser: webdriverBrowsers, - options, - 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 { - public name = 'webdriverio' as const - public supportsParallelism: boolean = false - - public browser: WebdriverIO.Browser | null = null - - private browserName!: WebdriverBrowser - private project!: TestProject - - private options?: WebdriverProviderOptions - - private closing = false - private iframeSwitched = false - private topLevelContext: string | undefined - - getSupportedBrowsers(): readonly string[] { - return webdriverBrowsers - } - - constructor( - project: TestProject, - options: WebdriverProviderOptions, - ) { - // increase shutdown timeout because WDIO takes some extra time to kill the driver - if (!project.vitest.state._data.timeoutIncreased) { - project.vitest.state._data.timeoutIncreased = true - project.vitest.config.teardownTimeout += 10_000 - } - - this.closing = false - this.project = project - this.browserName = project.config.browser.name as WebdriverBrowser - this.options = options - } - - isIframeSwitched(): boolean { - return this.iframeSwitched - } - - async switchToTestFrame(): Promise { - const browser = this.browser! - // support wdio@9 - if (browser.switchFrame) { - await browser.switchFrame(browser.$('iframe[data-vitest]')) - } - else { - const iframe = await browser.findElement( - 'css selector', - 'iframe[data-vitest]', - ) - await browser.switchToFrame(iframe) - } - this.iframeSwitched = true - } - - async switchToMainFrame(): Promise { - const page = this.browser! - if (page.switchFrame) { - await page.switchFrame(null) - } - else { - await page.switchToParentFrame() - } - this.iframeSwitched = false - } - - async setViewport(options: { width: number; height: number }): Promise { - if (this.topLevelContext == null || !this.browser) { - throw new Error(`The browser has no open pages.`) - } - await this.browser.send({ - method: 'browsingContext.setViewport', - params: { - context: this.topLevelContext, - devicePixelRatio: 1, - viewport: options, - }, - }) - } - - getCommandsContext(): { - browser: WebdriverIO.Browser | null - } { - return { - browser: this.browser, - } - } - - async openBrowser(): Promise { - await this._throwIfClosing('opening the browser') - - if (this.browser) { - debug?.('[%s] the browser is already opened, reusing it', this.browserName) - return this.browser - } - - const options = this.project.config.browser - - if (this.browserName === 'safari') { - if (options.headless) { - throw new Error( - 'You\'ve enabled headless mode for Safari but it doesn\'t currently support it.', - ) - } - } - - const { remote } = await import('webdriverio') - - const remoteOptions: Capabilities.WebdriverIOConfig = { - logLevel: 'silent', - ...this.options, - capabilities: this.buildCapabilities(), - } - - debug?.('[%s] opening the browser with options: %O', this.browserName, remoteOptions) - // TODO: close everything, if browser is closed from the outside - this.browser = await remote(remoteOptions) - await this._throwIfClosing() - - return this.browser - } - - private buildCapabilities() { - const capabilities: Capabilities.WebdriverIOConfig['capabilities'] = { - ...this.options?.capabilities, - browserName: this.browserName, - } - - const headlessMap = { - chrome: ['goog:chromeOptions', ['headless', 'disable-gpu']], - firefox: ['moz:firefoxOptions', ['-headless']], - edge: ['ms:edgeOptions', ['--headless']], - } as const - - const options = this.project.config.browser - const browser = this.browserName - if (browser !== 'safari' && options.headless) { - const [key, args] = headlessMap[browser] - const currentValues = (this.options?.capabilities as any)?.[key] || {} - const newArgs = [...(currentValues.args || []), ...args] - capabilities[key] = { ...currentValues, args: newArgs as any } - } - - // start Vitest UI maximized only on supported browsers - if (options.ui && (browser === 'chrome' || browser === 'edge')) { - const key = browser === 'chrome' - ? 'goog:chromeOptions' - : 'ms:edgeOptions' - const args = capabilities[key]?.args || [] - if (!args.includes('--start-maximized') && !args.includes('--start-fullscreen')) { - args.push('--start-maximized') - } - capabilities[key] ??= {} - capabilities[key]!.args = args - } - - const inspector = this.project.vitest.config.inspector - if (inspector.enabled && (browser === 'chrome' || browser === 'edge')) { - const key = browser === 'chrome' - ? 'goog:chromeOptions' - : 'ms:edgeOptions' - const args = capabilities[key]?.args || [] - - // NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector - const port = inspector.port || 9229 - const host = inspector.host || '127.0.0.1' - - args.push(`--remote-debugging-port=${port}`) - args.push(`--remote-debugging-address=${host}`) - - this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`) - - capabilities[key] ??= {} - capabilities[key]!.args = args - } - - return capabilities - } - - async openPage(sessionId: string, url: string): Promise { - await this._throwIfClosing('creating the browser') - debug?.('[%s][%s] creating the browser page for %s', sessionId, this.browserName, url) - const browserInstance = await this.openBrowser() - debug?.('[%s][%s] browser page is created, opening %s', sessionId, this.browserName, url) - await browserInstance.url(url) - this.topLevelContext = await browserInstance.getWindowHandle() - await this._throwIfClosing('opening the url') - } - - private async _throwIfClosing(action?: string) { - if (this.closing) { - debug?.(`[%s] provider was closed, cannot perform the action${action ? ` ${action}` : ''}`, this.browserName) - await (this.browser?.sessionId ? this.browser?.deleteSession?.() : null) - throw new Error(`[vitest] The provider was closed.`) - } - } - - async close(): Promise { - debug?.('[%s] closing provider', this.browserName) - this.closing = true - const browser = this.browser - const sessionId = browser?.sessionId - if (!browser || !sessionId) { - return - } - - // https://github.com/webdriverio/webdriverio/blob/ab1a2e82b13a9c7d0e275ae87e7357e1b047d8d3/packages/wdio-runner/src/index.ts#L486 - await browser.deleteSession() - browser.sessionId = undefined as unknown as string - this.browser = null - } - - async getCDPSession(_sessionId: string): Promise { - return { - send: (method: string, params: any) => { - if (!this.browser) { - throw new Error(`The environment was torn down.`) - } - return this.browser.sendCommandAndGetResult(method, params ?? {}).catch((error) => { - return Promise.reject(new Error(`Failed to execute "${method}" command.`, { cause: error })) - }) - }, - on: () => { - throw new Error(`webdriverio provider doesn't support cdp.on()`) - }, - once: () => { - throw new Error(`webdriverio provider doesn't support cdp.once()`) - }, - off: () => { - throw new Error(`webdriverio provider doesn't support cdp.off()`) - }, - } - } -} - -declare module 'vitest/node' { - export interface UserEventClickOptions extends ClickOptions {} - - export interface UserEventDragOptions extends DragAndDropOptions { - sourceX?: number - sourceY?: number - targetX?: number - targetY?: number - } - - export interface BrowserCommandContext { - browser: WebdriverIO.Browser - } - - export interface ToMatchScreenshotOptions - extends Omit< - ScreenshotMatcherOptions, - 'comparatorName' | 'comparatorOptions' - > {} - - export interface ToMatchScreenshotComparators - extends ScreenshotComparatorRegistry {} -} diff --git a/packages/coverage-v8/tsconfig.json b/packages/coverage-v8/tsconfig.json index 8ff7219b1b7e..93e30d6aadd0 100644 --- a/packages/coverage-v8/tsconfig.json +++ b/packages/coverage-v8/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "moduleResolution": "Bundler", - "types": ["@vitest/browser/providers/playwright"], "isolatedDeclarations": true }, "include": ["./src/**/*.ts"], diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b72cc701ea3..f8c551b06368 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -66,6 +66,7 @@ "@types/ws": "catalog:", "@unocss/reset": "catalog:", "@vitejs/plugin-vue": "catalog:", + "@vitest/browser-playwright": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/ws-client": "workspace:*", "@vue/test-utils": "^2.4.6", diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 1c3e3e320adf..075a3a10d2de 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { mergeConfig } from 'vite' import { defineConfig } from 'vitest/config' import viteConfig from './vite.config' diff --git a/packages/vitest/src/create/browser/creator.ts b/packages/vitest/src/create/browser/creator.ts index 6563c2868835..37e95b674f65 100644 --- a/packages/vitest/src/create/browser/creator.ts +++ b/packages/vitest/src/create/browser/creator.ts @@ -45,12 +45,12 @@ function getProviderPackageNames(provider: BrowserBuiltinProvider) { switch (provider) { case 'webdriverio': return { - types: '@vitest/browser/providers/webdriverio', + types: '@vitest/browser-webdriverio', pkg: 'webdriverio', } case 'playwright': return { - types: '@vitest/browser/providers/playwright', + types: '@vitest/browser-playwright', pkg: 'playwright', } case 'preview': @@ -295,7 +295,7 @@ async function generateFrameworkConfigFile(options: { const configContent = [ `import { defineConfig } from 'vitest/config'`, - `import { ${options.provider} } from '@vitest/browser/providers/${options.provider}'`, + `import { ${options.provider} } from '@vitest/browser-${options.provider}'`, options.frameworkPlugin ? frameworkImport : null, ``, 'export default defineConfig({', @@ -433,7 +433,7 @@ export async function create(): Promise { } const dependenciesToInstall = [ - '@vitest/browser', + `@vitest/browser-${provider}`, ] const frameworkPackage = getFrameworkTestPackage(framework) diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index b8e67fc636d3..1206363046de 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -755,7 +755,7 @@ export function resolveConfig( } if (typeof resolved.browser.provider === 'string') { - const source = `@vitest/browser/providers/${resolved.browser.provider}` + const source = `@vitest/browser-${resolved.browser.provider}` throw new TypeError( 'The `browser.provider` configuration was changed to accept a factory instead of a string. ' + `Add an import of "${resolved.browser.provider}" from "${source}" instead. See: https://vitest.dev/guide/browser/config#provider`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62cdf2a8d4b7..8bb4e9081954 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ catalogs: 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 @@ -351,9 +354,9 @@ importers: specifier: ^3.3.1 version: 3.3.1 devDependencies: - '@vitest/browser': + '@vitest/browser-playwright': specifier: workspace:* - version: link:../../packages/browser + version: link:../../packages/browser-playwright jsdom: specifier: latest version: 27.0.0(postcss@8.5.6) @@ -531,6 +534,22 @@ importers: specifier: workspace:* version: link:../vitest + packages/browser-preview: + dependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@vitest/browser': + specifier: workspace:* + version: link:../browser + devDependencies: + vitest: + specifier: workspace:* + version: link:../vitest + packages/browser-webdriverio: dependencies: '@vitest/browser': @@ -822,6 +841,9 @@ importers: '@vitejs/plugin-vue': specifier: 'catalog:' version: 6.0.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.93.0)(sass@1.93.0)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2)) + '@vitest/browser-playwright': + specifier: workspace:* + version: link:../browser-playwright '@vitest/runner': specifier: workspace:* version: link:../runner @@ -1145,6 +1167,12 @@ importers: '@vitest/browser-playwright': specifier: workspace:* version: link:../../packages/browser-playwright + '@vitest/browser-preview': + specifier: workspace:* + version: link:../../packages/browser-preview + '@vitest/browser-webdriverio': + specifier: workspace:* + version: link:../../packages/browser-webdriverio '@vitest/bundled-lib': specifier: link:./bundled-lib version: link:bundled-lib @@ -1190,6 +1218,9 @@ importers: '@vitejs/plugin-basic-ssl': specifier: ^2.1.0 version: 2.1.0(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.93.0)(sass@1.93.0)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/browser-playwright': + specifier: workspace:* + version: link:../../packages/browser-playwright '@vitest/runner': specifier: workspace:^ version: link:../../packages/runner @@ -1214,6 +1245,15 @@ importers: test/config: devDependencies: + '@vitest/browser-playwright': + specifier: workspace:* + version: link:../../packages/browser-playwright + '@vitest/browser-preview': + specifier: workspace:* + version: link:../../packages/browser-preview + '@vitest/browser-webdriverio': + specifier: workspace:* + version: link:../../packages/browser-webdriverio '@vitest/test-dep-conditions': specifier: file:./deps/test-dep-conditions version: file:test/config/deps/test-dep-conditions @@ -1337,9 +1377,9 @@ importers: '@vitejs/plugin-vue': specifier: latest version: 6.0.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.93.0)(sass@1.93.0)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2)) - '@vitest/browser': + '@vitest/browser-playwright': specifier: workspace:* - version: link:../../packages/browser + version: link:../../packages/browser-playwright '@vitest/coverage-istanbul': specifier: workspace:* version: link:../../packages/coverage-istanbul @@ -1397,9 +1437,9 @@ importers: test/dts-playwright: devDependencies: - '@vitest/browser': + '@vitest/browser-playwright': specifier: workspace:* - version: link:../../packages/browser + version: link:../../packages/browser-playwright vitest: specifier: workspace:* version: link:../../packages/vitest @@ -1522,9 +1562,12 @@ importers: test/watch: devDependencies: - '@vitest/browser': + '@vitest/browser-playwright': specifier: workspace:* - version: link:../../packages/browser + version: link:../../packages/browser-playwright + '@vitest/browser-webdriverio': + specifier: workspace:* + version: link:../../packages/browser-webdriverio vite: specifier: ^7.1.5 version: 7.1.5(@types/node@22.18.6)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.93.0)(sass@1.93.0)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) @@ -1552,9 +1595,9 @@ importers: test/workspaces-browser: devDependencies: - '@vitest/browser': + '@vitest/browser-playwright': specifier: workspace:* - version: link:../../packages/browser + version: link:../../packages/browser-playwright vitest: specifier: workspace:* version: link:../../packages/vitest diff --git a/test/browser/fixtures/trace-view/vitest.config.ts b/test/browser/fixtures/trace-view/vitest.config.ts index e7a19d7a1cf4..ca61d24c72f1 100644 --- a/test/browser/fixtures/trace-view/vitest.config.ts +++ b/test/browser/fixtures/trace-view/vitest.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' export default defineConfig({ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), diff --git a/test/browser/package.json b/test/browser/package.json index 5e5ef6364f44..b2c58d7d0015 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -34,6 +34,8 @@ "@vitejs/plugin-basic-ssl": "^2.1.0", "@vitest/browser": "workspace:*", "@vitest/browser-playwright": "workspace:*", + "@vitest/browser-preview": "workspace:*", + "@vitest/browser-webdriverio": "workspace:*", "@vitest/bundled-lib": "link:./bundled-lib", "@vitest/cjs-lib": "link:./cjs-lib", "playwright": "^1.55.0", diff --git a/test/browser/settings.ts b/test/browser/settings.ts index bb3b1754ed8d..a23a43185ce2 100644 --- a/test/browser/settings.ts +++ b/test/browser/settings.ts @@ -1,7 +1,7 @@ import type { BrowserInstanceOption } from 'vitest/node' import { playwright } from '@vitest/browser-playwright' -import { preview } from '@vitest/browser/providers/preview' -import { webdriverio } from '@vitest/browser/providers/webdriverio' +import { preview } from '@vitest/browser-preview' +import { webdriverio } from '@vitest/browser-webdriverio' const providerName = (process.env.PROVIDER || 'playwright') as 'playwright' | 'webdriverio' | 'preview' export const providers = { diff --git a/test/browser/specs/playwright-connect.test.ts b/test/browser/specs/playwright-connect.test.ts index 1d3cf6555726..7a1e8a715fc8 100644 --- a/test/browser/specs/playwright-connect.test.ts +++ b/test/browser/specs/playwright-connect.test.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { chromium } from 'playwright' import { expect, test } from 'vitest' import { provider } from '../settings' diff --git a/test/browser/specs/to-match-screenshot.test.ts b/test/browser/specs/to-match-screenshot.test.ts index 887357abc34a..b410991cdc0a 100644 --- a/test/browser/specs/to-match-screenshot.test.ts +++ b/test/browser/specs/to-match-screenshot.test.ts @@ -1,8 +1,8 @@ -import type { ViteUserConfig } from 'vitest/config.js' +import type { ViteUserConfig } from 'vitest/config' import type { TestFsStructure } from '../../test-utils' import { platform } from 'node:os' import { resolve } from 'node:path' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { describe, expect, test } from 'vitest' import { runVitestCli, useFS } from '../../test-utils' import { extractToMatchScreenshotPaths } from '../fixtures/expect-dom/utils' diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index ea9f169c02d2..a8d4e56c7f6c 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -3,8 +3,8 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import * as util from 'node:util' import { playwright } from '@vitest/browser-playwright' -import { preview } from '@vitest/browser/providers/preview' -import { webdriverio } from '@vitest/browser/providers/webdriverio' +import { preview } from '@vitest/browser-preview' +import { webdriverio } from '@vitest/browser-webdriverio' import { defineConfig } from 'vitest/config' const dir = dirname(fileURLToPath(import.meta.url)) diff --git a/test/cli/fixtures/browser-multiple/vitest.config.ts b/test/cli/fixtures/browser-multiple/vitest.config.ts index 1df074e7bcef..c525c6f3258d 100644 --- a/test/cli/fixtures/browser-multiple/vitest.config.ts +++ b/test/cli/fixtures/browser-multiple/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { resolve } from 'pathe'; import { defineConfig } from 'vitest/config'; diff --git a/test/cli/fixtures/config-loader/browser/vitest.config.ts b/test/cli/fixtures/config-loader/browser/vitest.config.ts index 4a0041e1b70a..0bde499fc44e 100644 --- a/test/cli/fixtures/config-loader/browser/vitest.config.ts +++ b/test/cli/fixtures/config-loader/browser/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "vitest/config" import "@test/test-dep-linked/ts"; -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; export default defineConfig({ test: { diff --git a/test/cli/fixtures/list/vitest.config.ts b/test/cli/fixtures/list/vitest.config.ts index 487485c4e133..a99b87bb6e7c 100644 --- a/test/cli/fixtures/list/vitest.config.ts +++ b/test/cli/fixtures/list/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' diff --git a/test/cli/fixtures/public-api/vitest.config.ts b/test/cli/fixtures/public-api/vitest.config.ts index 3fe34057ddff..8b5e2245d0e0 100644 --- a/test/cli/fixtures/public-api/vitest.config.ts +++ b/test/cli/fixtures/public-api/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vitest/config' export default defineConfig({ diff --git a/test/cli/package.json b/test/cli/package.json index cb1dd8153597..608e02911c9b 100644 --- a/test/cli/package.json +++ b/test/cli/package.json @@ -12,6 +12,7 @@ "@types/debug": "catalog:", "@types/ws": "catalog:", "@vitejs/plugin-basic-ssl": "^2.1.0", + "@vitest/browser-playwright": "workspace:*", "@vitest/runner": "workspace:^", "@vitest/utils": "workspace:*", "debug": "^4.4.3", diff --git a/test/cli/test/annotations.test.ts b/test/cli/test/annotations.test.ts index 414878805b3d..07eaa44ea414 100644 --- a/test/cli/test/annotations.test.ts +++ b/test/cli/test/annotations.test.ts @@ -1,5 +1,5 @@ import type { TestAnnotation } from 'vitest' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { describe, expect, test } from 'vitest' import { runInlineTests } from '../../test-utils' diff --git a/test/cli/test/fails.test.ts b/test/cli/test/fails.test.ts index 7efc4b4f9431..6d4ea8255883 100644 --- a/test/cli/test/fails.test.ts +++ b/test/cli/test/fails.test.ts @@ -1,5 +1,5 @@ import type { TestCase } from 'vitest/node' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { resolve } from 'pathe' import { glob } from 'tinyglobby' diff --git a/test/cli/test/init.test.ts b/test/cli/test/init.test.ts index c2c9f89ef3c1..dc3f725504ee 100644 --- a/test/cli/test/init.test.ts +++ b/test/cli/test/init.test.ts @@ -54,7 +54,7 @@ test('initializes project', async () => { expect(await getFileContent('/vitest.browser.config.ts')).toMatchInlineSnapshot(` "import { defineConfig } from 'vitest/config' - import { preview } from '@vitest/browser/providers/preview' + import { preview } from '@vitest/browser-preview' export default defineConfig({ test: { diff --git a/test/cli/test/scoped-fixtures.test.ts b/test/cli/test/scoped-fixtures.test.ts index 40ce7785f205..083edee8f713 100644 --- a/test/cli/test/scoped-fixtures.test.ts +++ b/test/cli/test/scoped-fixtures.test.ts @@ -4,7 +4,7 @@ import type { TestAPI } from 'vitest' import type { ViteUserConfig } from 'vitest/config' import type { TestSpecification, TestUserConfig } from 'vitest/node' import type { TestFsStructure } from '../../test-utils' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { runInlineTests } from '../../test-utils' interface TestContext { diff --git a/test/config/fixtures/browser-custom-html/vitest.config.correct.ts b/test/config/fixtures/browser-custom-html/vitest.config.correct.ts index cc6645a06060..cbd53f2d3615 100644 --- a/test/config/fixtures/browser-custom-html/vitest.config.correct.ts +++ b/test/config/fixtures/browser-custom-html/vitest.config.correct.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/test/config/fixtures/browser-custom-html/vitest.config.custom-transformIndexHtml.ts b/test/config/fixtures/browser-custom-html/vitest.config.custom-transformIndexHtml.ts index 136f218a63ee..71c7a8a48d10 100644 --- a/test/config/fixtures/browser-custom-html/vitest.config.custom-transformIndexHtml.ts +++ b/test/config/fixtures/browser-custom-html/vitest.config.custom-transformIndexHtml.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/test/config/fixtures/browser-custom-html/vitest.config.default-transformIndexHtml.ts b/test/config/fixtures/browser-custom-html/vitest.config.default-transformIndexHtml.ts index f8fe90543256..325eab8793a7 100644 --- a/test/config/fixtures/browser-custom-html/vitest.config.default-transformIndexHtml.ts +++ b/test/config/fixtures/browser-custom-html/vitest.config.default-transformIndexHtml.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/test/config/fixtures/browser-custom-html/vitest.config.error-hook.ts b/test/config/fixtures/browser-custom-html/vitest.config.error-hook.ts index 4ad8f42e41fc..452b517cd766 100644 --- a/test/config/fixtures/browser-custom-html/vitest.config.error-hook.ts +++ b/test/config/fixtures/browser-custom-html/vitest.config.error-hook.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/test/config/fixtures/browser-custom-html/vitest.config.non-existing.ts b/test/config/fixtures/browser-custom-html/vitest.config.non-existing.ts index 8a259fc9fff9..4e14f69e8593 100644 --- a/test/config/fixtures/browser-custom-html/vitest.config.non-existing.ts +++ b/test/config/fixtures/browser-custom-html/vitest.config.non-existing.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/test/config/package.json b/test/config/package.json index dd7c8c7b2d1a..43318e591051 100644 --- a/test/config/package.json +++ b/test/config/package.json @@ -6,6 +6,9 @@ "test": "vitest --typecheck.enabled" }, "devDependencies": { + "@vitest/browser-playwright": "workspace:*", + "@vitest/browser-preview": "workspace:*", + "@vitest/browser-webdriverio": "workspace:*", "@vitest/test-dep-conditions": "file:./deps/test-dep-conditions", "tinyexec": "^0.3.2", "vite": "latest", diff --git a/test/config/test/bail.test.ts b/test/config/test/bail.test.ts index 62f509dcae9b..9127851bd59e 100644 --- a/test/config/test/bail.test.ts +++ b/test/config/test/bail.test.ts @@ -1,6 +1,6 @@ import type { TestUserConfig } from 'vitest/node' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { expect, test } from 'vitest' import { runVitest } from '../../test-utils' diff --git a/test/config/test/browser-configs.test.ts b/test/config/test/browser-configs.test.ts index 87c6b004e8d8..89c9fa45a42f 100644 --- a/test/config/test/browser-configs.test.ts +++ b/test/config/test/browser-configs.test.ts @@ -2,9 +2,9 @@ import type { ViteUserConfig } from 'vitest/config' import type { TestUserConfig, VitestOptions } from 'vitest/node' import type { TestFsStructure } from '../../test-utils' import crypto from 'node:crypto' -import { playwright } from '@vitest/browser/providers/playwright' -import { webdriverio } from '@vitest/browser/providers/webdriverio' -import { preview } from '@vitest/browser/src/node/providers/preview.js' +import { playwright } from '@vitest/browser-playwright' +import { preview } from '@vitest/browser-preview' +import { webdriverio } from '@vitest/browser-webdriverio' import { resolve } from 'pathe' import { describe, expect, onTestFinished, test } from 'vitest' import { createVitest } from 'vitest/node' diff --git a/test/config/test/failures.test.ts b/test/config/test/failures.test.ts index cfb113b09900..c812fd928e8b 100644 --- a/test/config/test/failures.test.ts +++ b/test/config/test/failures.test.ts @@ -1,9 +1,9 @@ import type { UserConfig as ViteUserConfig } from 'vite' import type { TestUserConfig } from 'vitest/node' import type { VitestRunnerCLIOptions } from '../../test-utils' -import { playwright } from '@vitest/browser/providers/playwright' -import { preview } from '@vitest/browser/providers/preview' -import { webdriverio } from '@vitest/browser/providers/webdriverio' +import { playwright } from '@vitest/browser-playwright' +import { preview } from '@vitest/browser-preview' +import { webdriverio } from '@vitest/browser-webdriverio' import { normalize, resolve } from 'pathe' import { beforeEach, expect, test } from 'vitest' import { version } from 'vitest/package.json' diff --git a/test/coverage-test/package.json b/test/coverage-test/package.json index 4e640f5fe105..f40c3ddc2aa2 100644 --- a/test/coverage-test/package.json +++ b/test/coverage-test/package.json @@ -10,7 +10,7 @@ "@types/istanbul-lib-coverage": "catalog:", "@types/istanbul-lib-report": "catalog:", "@vitejs/plugin-vue": "latest", - "@vitest/browser": "workspace:*", + "@vitest/browser-playwright": "workspace:*", "@vitest/coverage-istanbul": "workspace:*", "@vitest/coverage-v8": "workspace:*", "@vitest/web-worker": "workspace:*", diff --git a/test/coverage-test/utils.ts b/test/coverage-test/utils.ts index 764f13e2e0d3..d34f796566ee 100644 --- a/test/coverage-test/utils.ts +++ b/test/coverage-test/utils.ts @@ -7,7 +7,7 @@ import { unlink } from 'node:fs/promises' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import libCoverage from 'istanbul-lib-coverage' import { normalize } from 'pathe' import { vi, describe as vitestDescribe, test as vitestTest } from 'vitest' diff --git a/test/dts-playwright/package.json b/test/dts-playwright/package.json index c53fbd8f5dc5..0b094aae7db7 100644 --- a/test/dts-playwright/package.json +++ b/test/dts-playwright/package.json @@ -6,7 +6,7 @@ "test": "tsc -b" }, "devDependencies": { - "@vitest/browser": "workspace:*", + "@vitest/browser-playwright": "workspace:*", "vitest": "workspace:*" } } diff --git a/test/dts-playwright/tsconfig.json b/test/dts-playwright/tsconfig.json index 66b2370dff40..ef7768529b99 100644 --- a/test/dts-playwright/tsconfig.json +++ b/test/dts-playwright/tsconfig.json @@ -4,7 +4,6 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", - "types": ["@vitest/browser/providers/playwright"], "strict": true, "noEmit": true }, diff --git a/test/dts-playwright/vite.config.ts b/test/dts-playwright/vite.config.ts index 0c32e8096600..f471d86a3ea6 100644 --- a/test/dts-playwright/vite.config.ts +++ b/test/dts-playwright/vite.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vitest/config' export default defineConfig({ diff --git a/test/watch/package.json b/test/watch/package.json index d4fc79036824..48a6ddf442e2 100644 --- a/test/watch/package.json +++ b/test/watch/package.json @@ -6,7 +6,8 @@ "test": "vitest" }, "devDependencies": { - "@vitest/browser": "workspace:*", + "@vitest/browser-playwright": "workspace:*", + "@vitest/browser-webdriverio": "workspace:*", "vite": "latest", "vitest": "workspace:*" } diff --git a/test/watch/test/config-watching.test.ts b/test/watch/test/config-watching.test.ts index 9edc214e3cf6..f4c8fc3d4220 100644 --- a/test/watch/test/config-watching.test.ts +++ b/test/watch/test/config-watching.test.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { expect, test } from 'vitest' import { runInlineTests } from '../../test-utils' diff --git a/test/watch/test/file-watching.test.ts b/test/watch/test/file-watching.test.ts index 451ec6aa37d1..dbed603471ac 100644 --- a/test/watch/test/file-watching.test.ts +++ b/test/watch/test/file-watching.test.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { webdriverio } from '@vitest/browser/providers/webdriverio' +import { webdriverio } from '@vitest/browser-webdriverio' import { afterEach, describe, expect, test } from 'vitest' import * as testUtils from '../../test-utils' diff --git a/test/workspaces-browser/package.json b/test/workspaces-browser/package.json index 5cd40bf42095..1ef72c903e1c 100644 --- a/test/workspaces-browser/package.json +++ b/test/workspaces-browser/package.json @@ -6,7 +6,7 @@ "test": "vitest run" }, "devDependencies": { - "@vitest/browser": "workspace:^", + "@vitest/browser-playwright": "workspace:^", "vitest": "workspace:*" } } diff --git a/test/workspaces-browser/space_browser/vitest.config.ts b/test/workspaces-browser/space_browser/vitest.config.ts index 246901f82f82..76fb1f7ea3ad 100644 --- a/test/workspaces-browser/space_browser/vitest.config.ts +++ b/test/workspaces-browser/space_browser/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { defineProject } from 'vitest/config' export default defineProject({ diff --git a/test/workspaces-browser/vitest.config.ts b/test/workspaces-browser/vitest.config.ts index b550364fce73..dc590ffaf1fd 100644 --- a/test/workspaces-browser/vitest.config.ts +++ b/test/workspaces-browser/vitest.config.ts @@ -1,4 +1,4 @@ -import { playwright } from '@vitest/browser/providers/playwright' +import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vitest/config' if (process.env.TEST_WATCH) { From b0fa5b4884d072d829b33ec74d064847cdbfce4e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 12:17:05 +0200 Subject: [PATCH 07/43] feat: register playwright commands --- .../browser-playwright/src/commands/clear.ts | 11 ++ .../browser-playwright/src/commands/click.ts | 32 +++++ .../src/commands/dragAndDrop.ts | 16 +++ .../browser-playwright/src/commands/fill.ts | 13 ++ .../browser-playwright/src/commands/hover.ts | 10 ++ .../browser-playwright/src/commands/index.ts | 40 ++++++ .../src/commands/keyboard.ts | 133 ++++++++++++++++++ .../src/commands/screenshot.ts | 115 +++++++++++++++ .../browser-playwright/src/commands/select.ts | 27 ++++ .../browser-playwright/src/commands/tab.ts | 10 ++ .../browser-playwright/src/commands/trace.ts | 125 ++++++++++++++++ .../browser-playwright/src/commands/type.ts | 33 +++++ .../browser-playwright/src/commands/upload.ts | 33 +++++ .../browser-playwright/src/commands/utils.ts | 17 +++ packages/browser-playwright/src/playwright.ts | 8 +- .../browser/src/node/commands/screenshot.ts | 16 ++- packages/browser/src/node/index.ts | 3 +- packages/browser/src/node/project.ts | 17 +++ packages/browser/src/node/rpc.ts | 5 +- packages/browser/src/node/utils.ts | 39 ++++- packages/vitest/src/node/types/browser.ts | 16 ++- tsconfig.base.json | 1 + 22 files changed, 713 insertions(+), 7 deletions(-) create mode 100644 packages/browser-playwright/src/commands/clear.ts create mode 100644 packages/browser-playwright/src/commands/click.ts create mode 100644 packages/browser-playwright/src/commands/dragAndDrop.ts create mode 100644 packages/browser-playwright/src/commands/fill.ts create mode 100644 packages/browser-playwright/src/commands/hover.ts create mode 100644 packages/browser-playwright/src/commands/index.ts create mode 100644 packages/browser-playwright/src/commands/keyboard.ts create mode 100644 packages/browser-playwright/src/commands/screenshot.ts create mode 100644 packages/browser-playwright/src/commands/select.ts create mode 100644 packages/browser-playwright/src/commands/tab.ts create mode 100644 packages/browser-playwright/src/commands/trace.ts create mode 100644 packages/browser-playwright/src/commands/type.ts create mode 100644 packages/browser-playwright/src/commands/upload.ts create mode 100644 packages/browser-playwright/src/commands/utils.ts diff --git a/packages/browser-playwright/src/commands/clear.ts b/packages/browser-playwright/src/commands/clear.ts new file mode 100644 index 000000000000..6736b36f70eb --- /dev/null +++ b/packages/browser-playwright/src/commands/clear.ts @@ -0,0 +1,11 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const clear: UserEventCommand = async ( + context, + selector, +) => { + const { iframe } = context + const element = iframe.locator(selector) + await element.clear() +} diff --git a/packages/browser-playwright/src/commands/click.ts b/packages/browser-playwright/src/commands/click.ts new file mode 100644 index 000000000000..75261a93aed3 --- /dev/null +++ b/packages/browser-playwright/src/commands/click.ts @@ -0,0 +1,32 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const click: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + const tester = context.iframe + await tester.locator(selector).click(options) +} + +export const dblClick: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + const tester = context.iframe + await tester.locator(selector).dblclick(options) +} + +export const tripleClick: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + const tester = context.iframe + await tester.locator(selector).click({ + ...options, + clickCount: 3, + }) +} diff --git a/packages/browser-playwright/src/commands/dragAndDrop.ts b/packages/browser-playwright/src/commands/dragAndDrop.ts new file mode 100644 index 000000000000..2febe3704101 --- /dev/null +++ b/packages/browser-playwright/src/commands/dragAndDrop.ts @@ -0,0 +1,16 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const dragAndDrop: UserEventCommand = async ( + context, + source, + target, + options_, +) => { + const frame = await context.frame() + await frame.dragAndDrop( + source, + target, + options_, + ) +} diff --git a/packages/browser-playwright/src/commands/fill.ts b/packages/browser-playwright/src/commands/fill.ts new file mode 100644 index 000000000000..a0a6b2dd3612 --- /dev/null +++ b/packages/browser-playwright/src/commands/fill.ts @@ -0,0 +1,13 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const fill: UserEventCommand = async ( + context, + selector, + text, + options = {}, +) => { + const { iframe } = context + const element = iframe.locator(selector) + await element.fill(text, options) +} diff --git a/packages/browser-playwright/src/commands/hover.ts b/packages/browser-playwright/src/commands/hover.ts new file mode 100644 index 000000000000..30afcd259073 --- /dev/null +++ b/packages/browser-playwright/src/commands/hover.ts @@ -0,0 +1,10 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const hover: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + await context.iframe.locator(selector).hover(options) +} diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts new file mode 100644 index 000000000000..2714f873a023 --- /dev/null +++ b/packages/browser-playwright/src/commands/index.ts @@ -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 { screenshot } 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_screenshot: screenshot as typeof screenshot, + __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, +} diff --git a/packages/browser-playwright/src/commands/keyboard.ts b/packages/browser-playwright/src/commands/keyboard.ts new file mode 100644 index 000000000000..70f776f7489b --- /dev/null +++ b/packages/browser-playwright/src/commands/keyboard.ts @@ -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(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> = 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, + provider: BrowserProvider, + sessionId: string, + text: string, + selectAll: () => Promise, + skipRelease: boolean, +): Promise<{ pressed: Set }> { + 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() + } +} diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts new file mode 100644 index 000000000000..7c1058260047 --- /dev/null +++ b/packages/browser-playwright/src/commands/screenshot.ts @@ -0,0 +1,115 @@ +import type { ScreenshotOptions } from 'vitest/browser' +import type { BrowserCommand, BrowserCommandContext, ResolvedConfig } from 'vitest/node' +import { mkdir } from 'node:fs/promises' +import { basename, dirname, normalize, relative, resolve } from 'pathe' + +interface ScreenshotCommandOptions extends Omit { + element?: string + mask?: readonly string[] +} + +export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async ( + context, + name: string, + options = {}, +) => { + options.save ??= true + + if (!options.save) { + options.base64 = true + } + + const { buffer, path } = await takeScreenshot(context, name, options) + + return returnResult(options, path, buffer) +} + +/** + * 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, +): Promise<{ buffer: Buffer; 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 } +} + +function resolveScreenshotPath( + testPath: string, + name: string, + config: ResolvedConfig, + customPath: string | undefined, +): string { + if (customPath) { + return resolve(dirname(testPath), customPath) + } + const dir = dirname(testPath) + const base = basename(testPath) + if (config.browser.screenshotDirectory) { + return resolve( + config.browser.screenshotDirectory, + relative(config.root, dir), + base, + name, + ) + } + return resolve(dir, '__screenshots__', base, name) +} + +function returnResult( + options: ScreenshotCommandOptions, + path: string, + buffer: Buffer, +) { + if (!options.save) { + return buffer.toString('base64') + } + if (options.base64) { + return { path, base64: buffer.toString('base64') } + } + return path +} diff --git a/packages/browser-playwright/src/commands/select.ts b/packages/browser-playwright/src/commands/select.ts new file mode 100644 index 000000000000..4fbcb972b87a --- /dev/null +++ b/packages/browser-playwright/src/commands/select.ts @@ -0,0 +1,27 @@ +import type { ElementHandle } from 'playwright' +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const selectOptions: UserEventCommand = 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) +} diff --git a/packages/browser-playwright/src/commands/tab.ts b/packages/browser-playwright/src/commands/tab.ts new file mode 100644 index 000000000000..8720e81088a7 --- /dev/null +++ b/packages/browser-playwright/src/commands/tab.ts @@ -0,0 +1,10 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const tab: UserEventCommand = async ( + context, + options = {}, +) => { + const page = context.page + await page.keyboard.press(options.shift === true ? 'Shift+Tab' : 'Tab') +} diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts new file mode 100644 index 000000000000..4542ebddda17 --- /dev/null +++ b/packages/browser-playwright/src/commands/trace.ts @@ -0,0 +1,125 @@ +import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' +import { unlink } from 'node:fs/promises' +import { basename, dirname, relative, resolve } from 'pathe' +import { PlaywrightBrowserProvider } from '../playwright' + +export const startTracing: BrowserCommand<[]> = async ({ context, project, provider, sessionId }) => { + if (provider instanceof PlaywrightBrowserProvider) { + if (provider.tracingContexts.has(sessionId)) { + return + } + + provider.tracingContexts.add(sessionId) + const options = project.config.browser!.trace + await context.tracing.start({ + screenshots: options.screenshots ?? true, + snapshots: options.snapshots ?? true, + // currently, PW shows sources in private methods + sources: false, + }).catch(() => { + provider.tracingContexts.delete(sessionId) + }) + return + } + throw new TypeError(`The ${provider.name} provider does not support tracing.`) +} + +export const startChunkTrace: BrowserCommand<[{ name: string; title: string }]> = async ( + command, + { name, title }, +) => { + const { provider, sessionId, testPath, context } = command + if (!testPath) { + throw new Error(`stopChunkTrace cannot be called outside of the test file.`) + } + if (provider instanceof PlaywrightBrowserProvider) { + if (!provider.tracingContexts.has(sessionId)) { + await startTracing(command) + } + const path = resolveTracesPath(command, name) + provider.pendingTraces.set(path, sessionId) + await context.tracing.startChunk({ name, title }) + return + } + throw new TypeError(`The ${provider.name} provider does not support tracing.`) +} + +export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async ( + context, + { name }, +) => { + if (context.provider instanceof PlaywrightBrowserProvider) { + const path = resolveTracesPath(context, name) + context.provider.pendingTraces.delete(path) + await context.context.tracing.stopChunk({ path }) + return { tracePath: path } + } + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) +} + +function resolveTracesPath({ testPath, project }: BrowserCommandContext, name: string) { + if (!testPath) { + throw new Error(`This command can only be called inside a test file.`) + } + const options = project.config.browser!.trace + const sanitizedName = `${project.name.replace(/[^a-z0-9]/gi, '-')}-${name}.trace.zip` + if (options.tracesDir) { + return resolve(options.tracesDir, sanitizedName) + } + const dir = dirname(testPath) + const base = basename(testPath) + return resolve( + dir, + '__traces__', + base, + `${project.name.replace(/[^a-z0-9]/gi, '-')}-${name}.trace.zip`, + ) +} + +export const deleteTracing: BrowserCommand<[{ traces: string[] }]> = async ( + context, + { traces }, +) => { + if (!context.testPath) { + throw new Error(`stopChunkTrace cannot be called outside of the test file.`) + } + if (context.provider instanceof PlaywrightBrowserProvider) { + return Promise.all( + traces.map(trace => unlink(trace).catch((err) => { + if (err.code === 'ENOENT') { + // Ignore the error if the file doesn't exist + return + } + // Re-throw other errors + throw err + })), + ) + } + + throw new Error(`provider ${context.provider.name} is not supported`) +} + +export const annotateTraces: BrowserCommand<[{ traces: string[]; testId: string }]> = async ( + { project }, + { testId, traces }, +) => { + const vitest = project.vitest + await Promise.all(traces.map((trace) => { + const entity = vitest.state.getReportedEntityById(testId) + return vitest._testRun.annotate(testId, { + message: relative(project.config.root, trace), + type: 'traces', + attachment: { + path: trace, + contentType: 'application/octet-stream', + }, + location: entity?.location + ? { + file: entity.module.moduleId, + line: entity.location.line, + column: entity.location.column, + } + : undefined, + }) + })) +} diff --git a/packages/browser-playwright/src/commands/type.ts b/packages/browser-playwright/src/commands/type.ts new file mode 100644 index 000000000000..2a3d6469f578 --- /dev/null +++ b/packages/browser-playwright/src/commands/type.ts @@ -0,0 +1,33 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' +import { keyboardImplementation } from './keyboard' + +export const type: UserEventCommand = 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), + } +} diff --git a/packages/browser-playwright/src/commands/upload.ts b/packages/browser-playwright/src/commands/upload.ts new file mode 100644 index 000000000000..2851dbd5169c --- /dev/null +++ b/packages/browser-playwright/src/commands/upload.ts @@ -0,0 +1,33 @@ +import type { UserEventUploadOptions } from '@vitest/browser/context' +import type { UserEventCommand } from './utils' +import { resolve } from 'pathe' + +export const upload: UserEventCommand<(element: string, files: Array, 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) +} diff --git a/packages/browser-playwright/src/commands/utils.ts b/packages/browser-playwright/src/commands/utils.ts new file mode 100644 index 000000000000..454171f68b1d --- /dev/null +++ b/packages/browser-playwright/src/commands/utils.ts @@ -0,0 +1,17 @@ +import type { Locator } from '@vitest/browser/context' +import type { BrowserCommand } from 'vitest/node' + +export type UserEventCommand any> = BrowserCommand< + ConvertUserEventParameters> +> + +type ConvertElementToLocator = T extends Element | Locator ? string : T +type ConvertUserEventParameters = { + [K in keyof T]: ConvertElementToLocator; +} + +export function defineBrowserCommand( + fn: BrowserCommand, +): BrowserCommand { + return fn +} diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index e3db6742d923..4529ac3ba2f9 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -19,6 +19,7 @@ import type { Protocol } from 'playwright-core/types/protocol' import type { SourceMap } from 'rollup' import type { ResolvedConfig } from 'vite' import type { + BrowserCommand, BrowserModuleMocker, BrowserProvider, BrowserProviderOption, @@ -29,6 +30,7 @@ import { createBrowserServer } from '@vitest/browser' import { createManualModuleSource } from '@vitest/mocker/node' import c from 'tinyrainbow' import { createDebugger, isCSSRequest } from 'vitest/node' +import commands from './commands' const debug = createDebugger('vitest:browser:playwright') @@ -104,6 +106,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) { @@ -555,7 +561,7 @@ type PWSelectOptions = NonNullable[2]> type PWDragAndDropOptions = NonNullable[2]> type PWSetInputFiles = NonNullable[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 {} diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 14c9869142dd..aaa279ab4859 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -12,6 +12,18 @@ interface ScreenshotCommandOptions extends Omit Promise<{ + buffer: Buffer + path: string + }> + } +} + export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async ( context, name: string, @@ -23,11 +35,13 @@ export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = as options.base64 = true } - const { buffer, path } = await takeScreenshot(context, name, options) + const { buffer, path } = await context.triggerCommand('__vitest_takeScreenshot', name, options) return returnResult(options, path, buffer) } +// TODO: remove this + /** * Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path. * diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 81775d4ef331..703bfefb2a28 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -10,9 +10,10 @@ import { ParentBrowserProject } from './projectParent' import { setupBrowserRpc } from './rpc' export { createBrowserPool } from './pool' - export type { ProjectBrowser } from './project' +export { parseKeyDef } from './utils' + export const createBrowserServer: BrowserServerFactory = async (options) => { const project = options.project const configFile = project.vite.config.configFile diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts index d8a1f12bc3f4..4f11755ecd19 100644 --- a/packages/browser/src/node/project.ts +++ b/packages/browser/src/node/project.ts @@ -1,7 +1,9 @@ import type { StackTraceParserOptions } from '@vitest/utils/source-map' import type { ViteDevServer } from 'vite' import type { ParsedStack, SerializedConfig, TestError } from 'vitest' +import type { BrowserCommands } from 'vitest/browser' import type { + BrowserCommand, BrowserProvider, ProjectBrowser as IProjectBrowser, ResolvedConfig, @@ -57,6 +59,21 @@ export class ProjectBrowser implements IProjectBrowser { return this.parent.vite } + public registerCommand( + name: K, + cb: BrowserCommand< + Parameters, + ReturnType + >, + ): void { + if (!/^[a-z_$][\w$]*$/i.test(name)) { + throw new Error( + `Invalid command name "${name}". Only alphanumeric characters, $ and _ are allowed.`, + ) + } + this.parent.commands[name] = cb + } + wrapSerializedConfig(): SerializedConfig { const config = wrapConfig(this.project.serializedConfig) config.env ??= {} diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 16b84aea2e56..f008c9030d19 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -242,7 +242,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke } const commands = globalServer.commands if (!commands || !commands[command]) { - throw new Error(`Unknown command "${command}".`) + throw new Error(`Provider ${provider.name} does not support command "${command}".`) } const context = Object.assign( { @@ -251,6 +251,9 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke provider, contextId: sessionId, sessionId, + triggerCommand: (name: string, ...args: any[]) => { + return commands[name](context, ...args) + }, }, provider.getCommandsContext(sessionId), ) as any as BrowserCommandContext diff --git a/packages/browser/src/node/utils.ts b/packages/browser/src/node/utils.ts index 71ed636a8a56..f61ed87fdc6c 100644 --- a/packages/browser/src/node/utils.ts +++ b/packages/browser/src/node/utils.ts @@ -1,4 +1,41 @@ -import type { BrowserProvider, BrowserProviderOption, ResolvedBrowserOptions, TestProject } from 'vitest/node' +import type { + BrowserProvider, + BrowserProviderOption, + ResolvedBrowserOptions, + TestProject, +} from 'vitest/node' + +import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' +import { parseKeyDef as tlParse } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' + +declare enum DOM_KEY_LOCATION { + STANDARD = 0, + LEFT = 1, + RIGHT = 2, + NUMPAD = 3, +} + +interface keyboardKey { + /** Physical location on a keyboard */ + code?: string + /** Character or functional key descriptor */ + key?: string + /** Location on the keyboard for keys with multiple representation */ + location?: DOM_KEY_LOCATION + /** Does the character in `key` require/imply AltRight to be pressed? */ + altGr?: boolean + /** Does the character in `key` require/imply a shiftKey to be pressed? */ + shift?: boolean +} + +export function parseKeyDef(text: string): { + keyDef: keyboardKey + releasePrevious: boolean + releaseSelf: boolean + repeat: number +}[] { + return tlParse(defaultKeyMap, text) +} export function replacer(code: string, values: Record): string { return code.replace(/\{\s*(\w+)\s*\}/g, (_, key) => values[key] ?? _) diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 441b7dcd0e38..ccb4de6df3ac 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -3,6 +3,7 @@ import type { CancelReason } from '@vitest/runner' import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' import type { StackTraceParserOptions } from '@vitest/utils/source-map' import type { Plugin, ViteDevServer } from 'vite' +import type { BrowserCommands } from 'vitest/browser' import type { BrowserTraceViewMode } from '../../runtime/config' import type { BrowserTesterOptions } from '../../types/browser' import type { TestProject } from '../project' @@ -276,6 +277,10 @@ export interface BrowserCommandContext { provider: BrowserProvider project: TestProject sessionId: string + triggerCommand: ( + name: K, + ...args: Parameters + ) => ReturnType } export interface BrowserServerStateSession { @@ -308,10 +313,17 @@ export interface ProjectBrowser { initBrowserProvider: (project: TestProject) => Promise parseStacktrace: (stack: string) => ParsedStack[] parseErrorStacktrace: (error: TestError, options?: StackTraceParserOptions) => ParsedStack[] + registerCommand: ( + name: K, + cb: BrowserCommand< + Parameters, + ReturnType + > + ) => void } -export interface BrowserCommand { - (context: BrowserCommandContext, ...payload: Payload): Awaitable +export interface BrowserCommand { + (context: BrowserCommandContext, ...payload: Payload): Awaitable } export interface BrowserScript { diff --git a/tsconfig.base.json b/tsconfig.base.json index b127f95f647c..06d63c2573c9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,6 +26,7 @@ "vitest": ["./packages/vitest/src/public/index.ts"], "vitest/internal/module-runner": ["./packages/vitest/src/public/module-runner.ts"], "vitest/globals": ["./packages/vitest/globals.d.ts"], + "vitest/browser": ["./packages/vitest/browser/context.d.ts"], "vitest/*": ["./packages/vitest/src/public/*"], "vite-node": ["./packages/vite-node/src/index.ts"], "vite-node/*": ["./packages/vite-node/src/*"] From af88f996799198eb69020860f6039bff31e8b503 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 12:36:03 +0200 Subject: [PATCH 08/43] feat: add wdio commands --- .../browser-playwright/src/commands/index.ts | 4 +- .../src/commands/screenshot.ts | 58 +-- .../browser-webdriverio/src/commands/clear.ts | 10 + .../browser-webdriverio/src/commands/click.ts | 44 ++ .../src/commands/dragAndDrop.ts | 27 ++ .../browser-webdriverio/src/commands/fill.ts | 12 + .../browser-webdriverio/src/commands/hover.ts | 11 + .../browser-webdriverio/src/commands/index.ts | 30 ++ .../src/commands/keyboard.ts | 126 ++++++ .../src/commands/screenshot.ts | 70 ++++ .../src/commands/select.ts | 25 ++ .../browser-webdriverio/src/commands/tab.ts | 11 + .../browser-webdriverio/src/commands/type.ts | 38 ++ .../src/commands/upload.ts | 34 ++ .../browser-webdriverio/src/commands/utils.ts | 11 + .../src/commands/viewport.ts | 9 + .../browser-webdriverio/src/webdriverio.ts | 6 + packages/browser/src/node/index.ts | 3 +- packages/browser/src/node/pool.ts | 385 ------------------ packages/browser/src/node/project.ts | 1 + packages/browser/src/node/utils.ts | 24 ++ 21 files changed, 495 insertions(+), 444 deletions(-) create mode 100644 packages/browser-webdriverio/src/commands/clear.ts create mode 100644 packages/browser-webdriverio/src/commands/click.ts create mode 100644 packages/browser-webdriverio/src/commands/dragAndDrop.ts create mode 100644 packages/browser-webdriverio/src/commands/fill.ts create mode 100644 packages/browser-webdriverio/src/commands/hover.ts create mode 100644 packages/browser-webdriverio/src/commands/index.ts create mode 100644 packages/browser-webdriverio/src/commands/keyboard.ts create mode 100644 packages/browser-webdriverio/src/commands/screenshot.ts create mode 100644 packages/browser-webdriverio/src/commands/select.ts create mode 100644 packages/browser-webdriverio/src/commands/tab.ts create mode 100644 packages/browser-webdriverio/src/commands/type.ts create mode 100644 packages/browser-webdriverio/src/commands/upload.ts create mode 100644 packages/browser-webdriverio/src/commands/utils.ts create mode 100644 packages/browser-webdriverio/src/commands/viewport.ts delete mode 100644 packages/browser/src/node/pool.ts diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts index 2714f873a023..f143414771dd 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -4,7 +4,7 @@ import { dragAndDrop } from './dragAndDrop' import { fill } from './fill' import { hover } from './hover' import { keyboard, keyboardCleanup } from './keyboard' -import { screenshot } from './screenshot' +import { takeScreenshot } from './screenshot' import { selectOptions } from './select' import { tab } from './tab' import { @@ -22,7 +22,7 @@ export default { __vitest_click: click as typeof click, __vitest_dblClick: dblClick as typeof dblClick, __vitest_tripleClick: tripleClick as typeof tripleClick, - __vitest_screenshot: screenshot as typeof screenshot, + __vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot, __vitest_type: type as typeof type, __vitest_clear: clear as typeof clear, __vitest_fill: fill as typeof fill, diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts index 7c1058260047..9e6c18a76dde 100644 --- a/packages/browser-playwright/src/commands/screenshot.ts +++ b/packages/browser-playwright/src/commands/screenshot.ts @@ -1,29 +1,13 @@ import type { ScreenshotOptions } from 'vitest/browser' -import type { BrowserCommand, BrowserCommandContext, ResolvedConfig } from 'vitest/node' +import type { BrowserCommandContext } from 'vitest/node' import { mkdir } from 'node:fs/promises' -import { basename, dirname, normalize, relative, resolve } from 'pathe' +import { resolveScreenshotPath } from '@vitest/browser' +import { dirname, normalize } from 'pathe' interface ScreenshotCommandOptions extends Omit { element?: string mask?: readonly string[] } - -export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async ( - context, - name: string, - options = {}, -) => { - options.save ??= true - - if (!options.save) { - options.base64 = true - } - - const { buffer, path } = await takeScreenshot(context, name, options) - - return returnResult(options, path, buffer) -} - /** * Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path. * @@ -77,39 +61,3 @@ export async function takeScreenshot( }) return { buffer, path } } - -function resolveScreenshotPath( - testPath: string, - name: string, - config: ResolvedConfig, - customPath: string | undefined, -): string { - if (customPath) { - return resolve(dirname(testPath), customPath) - } - const dir = dirname(testPath) - const base = basename(testPath) - if (config.browser.screenshotDirectory) { - return resolve( - config.browser.screenshotDirectory, - relative(config.root, dir), - base, - name, - ) - } - return resolve(dir, '__screenshots__', base, name) -} - -function returnResult( - options: ScreenshotCommandOptions, - path: string, - buffer: Buffer, -) { - if (!options.save) { - return buffer.toString('base64') - } - if (options.base64) { - return { path, base64: buffer.toString('base64') } - } - return path -} diff --git a/packages/browser-webdriverio/src/commands/clear.ts b/packages/browser-webdriverio/src/commands/clear.ts new file mode 100644 index 000000000000..214f72afa3c8 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/clear.ts @@ -0,0 +1,10 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const clear: UserEventCommand = async ( + context, + selector, +) => { + const browser = context.browser + await browser.$(selector).clearValue() +} diff --git a/packages/browser-webdriverio/src/commands/click.ts b/packages/browser-webdriverio/src/commands/click.ts new file mode 100644 index 000000000000..292103eba281 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/click.ts @@ -0,0 +1,44 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const click: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + const browser = context.browser + await browser.$(selector).click(options as any) +} + +export const dblClick: UserEventCommand = async ( + context, + selector, + _options = {}, +) => { + const browser = context.browser + await browser.$(selector).doubleClick() +} + +export const tripleClick: UserEventCommand = 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() +} diff --git a/packages/browser-webdriverio/src/commands/dragAndDrop.ts b/packages/browser-webdriverio/src/commands/dragAndDrop.ts new file mode 100644 index 000000000000..549f4dbccabb --- /dev/null +++ b/packages/browser-webdriverio/src/commands/dragAndDrop.ts @@ -0,0 +1,27 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const dragAndDrop: UserEventCommand = 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() +} diff --git a/packages/browser-webdriverio/src/commands/fill.ts b/packages/browser-webdriverio/src/commands/fill.ts new file mode 100644 index 000000000000..f3f0b1ebb356 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/fill.ts @@ -0,0 +1,12 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const fill: UserEventCommand = async ( + context, + selector, + text, + _options = {}, +) => { + const browser = context.browser + await browser.$(selector).setValue(text) +} diff --git a/packages/browser-webdriverio/src/commands/hover.ts b/packages/browser-webdriverio/src/commands/hover.ts new file mode 100644 index 000000000000..3a0f5d248f06 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/hover.ts @@ -0,0 +1,11 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const hover: UserEventCommand = async ( + context, + selector, + options = {}, +) => { + const browser = context.browser + await browser.$(selector).moveTo(options as any) +} diff --git a/packages/browser-webdriverio/src/commands/index.ts b/packages/browser-webdriverio/src/commands/index.ts new file mode 100644 index 000000000000..a589c0265fb6 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/index.ts @@ -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, +} diff --git a/packages/browser-webdriverio/src/commands/keyboard.ts b/packages/browser-webdriverio/src/commands/keyboard.ts new file mode 100644 index 000000000000..1af975c925b5 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/keyboard.ts @@ -0,0 +1,126 @@ +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(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> = 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, + provider: BrowserProvider, + _sessionId: string, + text: string, + selectAll: () => Promise, + skipRelease: boolean, +): Promise<{ pressed: Set }> { + 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() + } +} diff --git a/packages/browser-webdriverio/src/commands/screenshot.ts b/packages/browser-webdriverio/src/commands/screenshot.ts new file mode 100644 index 000000000000..1d090c6f3ce8 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/screenshot.ts @@ -0,0 +1,70 @@ +import type { ScreenshotOptions } from 'vitest/browser' +import type { BrowserCommandContext } from 'vitest/node' +import { mkdir, rm } from 'node:fs/promises' +import { normalize as platformNormalize } from 'node:path' +import { resolveScreenshotPath } from '@vitest/browser' +import { nanoid } from '@vitest/utils/helpers' +import { dirname, normalize, resolve } from 'pathe' + +interface ScreenshotCommandOptions extends Omit { + 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, +): Promise<{ buffer: Buffer; 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, nanoid()) + + 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 } +} diff --git a/packages/browser-webdriverio/src/commands/select.ts b/packages/browser-webdriverio/src/commands/select.ts new file mode 100644 index 000000000000..409633803707 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/select.ts @@ -0,0 +1,25 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +export const selectOptions: UserEventCommand = 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') + } +} diff --git a/packages/browser-webdriverio/src/commands/tab.ts b/packages/browser-webdriverio/src/commands/tab.ts new file mode 100644 index 000000000000..3d9fc77b8eda --- /dev/null +++ b/packages/browser-webdriverio/src/commands/tab.ts @@ -0,0 +1,11 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' +import { Key } from 'webdriverio' + +export const tab: UserEventCommand = async ( + context, + options = {}, +) => { + const browser = context.browser + await browser.keys(options.shift === true ? [Key.Shift, Key.Tab] : [Key.Tab]) +} diff --git a/packages/browser-webdriverio/src/commands/type.ts b/packages/browser-webdriverio/src/commands/type.ts new file mode 100644 index 000000000000..24b71aa58775 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/type.ts @@ -0,0 +1,38 @@ +import type { UserEvent } from 'vitest/browser' +import type { UserEventCommand } from './utils' +import { keyboardImplementation } from './keyboard' + +export const type: UserEventCommand = 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), + } +} diff --git a/packages/browser-webdriverio/src/commands/upload.ts b/packages/browser-webdriverio/src/commands/upload.ts new file mode 100644 index 000000000000..48d8c9aaf1c2 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/upload.ts @@ -0,0 +1,34 @@ +import type { UserEventUploadOptions } from '@vitest/browser/context' +import type { UserEventCommand } from './utils' +import { resolve } from 'pathe' + +export const upload: UserEventCommand<(element: string, files: Array, 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) + } +} diff --git a/packages/browser-webdriverio/src/commands/utils.ts b/packages/browser-webdriverio/src/commands/utils.ts new file mode 100644 index 000000000000..89afac85af70 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/utils.ts @@ -0,0 +1,11 @@ +import type { Locator } from 'vitest/browser' +import type { BrowserCommand } from 'vitest/node' + +export type UserEventCommand any> = BrowserCommand< + ConvertUserEventParameters> +> + +type ConvertElementToLocator = T extends Element | Locator ? string : T +type ConvertUserEventParameters = { + [K in keyof T]: ConvertElementToLocator; +} diff --git a/packages/browser-webdriverio/src/commands/viewport.ts b/packages/browser-webdriverio/src/commands/viewport.ts new file mode 100644 index 000000000000..98111fad6608 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/viewport.ts @@ -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) +} diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index bc01107d6a93..0cb17d9e2fae 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -4,6 +4,7 @@ import type { } from '@vitest/browser/context' import type { Capabilities } from '@wdio/types' import type { + BrowserCommand, BrowserProvider, BrowserProviderOption, CDPSession, @@ -13,6 +14,7 @@ import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio' import { createBrowserServer } from '@vitest/browser' import { createDebugger } from 'vitest/node' +import commands from './commands' const debug = createDebugger('vitest:browser:wdio') @@ -68,6 +70,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 { diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 703bfefb2a28..d5ce35ab629c 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -9,10 +9,9 @@ import BrowserPlugin from './plugin' import { ParentBrowserProject } from './projectParent' import { setupBrowserRpc } from './rpc' -export { createBrowserPool } from './pool' export type { ProjectBrowser } from './project' -export { parseKeyDef } from './utils' +export { parseKeyDef, resolveScreenshotPath } from './utils' export const createBrowserServer: BrowserServerFactory = async (options) => { const project = options.project diff --git a/packages/browser/src/node/pool.ts b/packages/browser/src/node/pool.ts deleted file mode 100644 index 994b88b3ceb3..000000000000 --- a/packages/browser/src/node/pool.ts +++ /dev/null @@ -1,385 +0,0 @@ -import type { DeferPromise } from '@vitest/utils/helpers' -import type { - BrowserProvider, - ProcessPool, - TestProject, - TestSpecification, - Vitest, -} from 'vitest/node' -import crypto from 'node:crypto' -import * as nodeos from 'node:os' -import { performance } from 'node:perf_hooks' -import { createDefer } from '@vitest/utils/helpers' -import { stringify } from 'flatted' -import { createDebugger } from 'vitest/node' - -const debug = createDebugger('vitest:browser:pool') - -export function createBrowserPool(vitest: Vitest): ProcessPool { - const providers = new Set() - - const numCpus - = typeof nodeos.availableParallelism === 'function' - ? nodeos.availableParallelism() - : nodeos.cpus().length - - const threadsCount = vitest.config.watch - ? Math.max(Math.floor(numCpus / 2), 1) - : Math.max(numCpus - 1, 1) - - const projectPools = new WeakMap() - - const ensurePool = (project: TestProject) => { - if (projectPools.has(project)) { - return projectPools.get(project)! - } - - debug?.('creating pool for project %s', project.name) - - const resolvedUrls = project.browser!.vite.resolvedUrls - const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] - - if (!origin) { - throw new Error( - `Can't find browser origin URL for project "${project.name}"`, - ) - } - - const pool: BrowserPool = new BrowserPool(project, { - maxWorkers: getThreadsCount(project), - origin, - }) - projectPools.set(project, pool) - vitest.onCancel(() => { - pool.cancel() - }) - - return pool - } - - const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { - const groupedFiles = new Map() - for (const { project, moduleId } of specs) { - const files = groupedFiles.get(project) || [] - files.push(moduleId) - groupedFiles.set(project, files) - } - - let isCancelled = false - vitest.onCancel(() => { - isCancelled = true - }) - - const initialisedPools = await Promise.all([...groupedFiles.entries()].map(async ([project, files]) => { - await project._initBrowserProvider() - - if (!project.browser) { - throw new TypeError(`The browser server was not initialized${project.name ? ` for the "${project.name}" project` : ''}. This is a bug in Vitest. Please, open a new issue with reproduction.`) - } - - if (isCancelled) { - return - } - - debug?.('provider is ready for %s project', project.name) - - const pool = ensurePool(project) - vitest.state.clearFiles(project, files) - providers.add(project.browser!.provider) - - return { - pool, - provider: project.browser!.provider, - runTests: () => pool.runTests(method, files), - } - })) - - if (isCancelled) { - return - } - - const parallelPools: (() => Promise)[] = [] - const nonParallelPools: (() => Promise)[] = [] - - for (const pool of initialisedPools) { - if (!pool) { - // this means it was cancelled - return - } - - if (pool.provider.mocker && pool.provider.supportsParallelism) { - parallelPools.push(pool.runTests) - } - else { - nonParallelPools.push(pool.runTests) - } - } - - await Promise.all(parallelPools.map(runTests => runTests())) - - for (const runTests of nonParallelPools) { - if (isCancelled) { - return - } - - await runTests() - } - } - - function getThreadsCount(project: TestProject) { - const config = project.config.browser - if ( - !config.headless - || !config.fileParallelism - || !project.browser!.provider.supportsParallelism - ) { - return 1 - } - - if (project.config.maxWorkers) { - return project.config.maxWorkers - } - - return threadsCount - } - - return { - name: 'browser', - async close() { - await Promise.all([...providers].map(provider => provider.close())) - vitest._browserSessions.sessionIds.clear() - providers.clear() - vitest.projects.forEach((project) => { - project.browser?.state.orchestrators.forEach((orchestrator) => { - orchestrator.$close() - }) - }) - debug?.('browser pool closed all providers') - }, - runTests: files => runWorkspaceTests('run', files), - collectTests: files => runWorkspaceTests('collect', files), - } -} - -function escapePathToRegexp(path: string): string { - return path.replace(/[/\\.?*()^${}|[\]+]/g, '\\$&') -} - -class BrowserPool { - private _queue: string[] = [] - private _promise: DeferPromise | undefined - private _providedContext: string | undefined - - private readySessions = new Set() - - constructor( - private project: TestProject, - private options: { - maxWorkers: number - origin: string - }, - ) {} - - public cancel(): void { - this._queue = [] - } - - public reject(error: Error): void { - this._promise?.reject(error) - this._promise = undefined - this.cancel() - } - - get orchestrators() { - return this.project.browser!.state.orchestrators - } - - async runTests(method: 'run' | 'collect', files: string[]): Promise { - this._promise ??= createDefer() - - if (!files.length) { - debug?.('no tests found, finishing test run immediately') - this._promise.resolve() - return this._promise - } - - this._providedContext = stringify(this.project.getProvidedContext()) - - this._queue.push(...files) - - this.readySessions.forEach((sessionId) => { - if (this._queue.length) { - this.readySessions.delete(sessionId) - this.runNextTest(method, sessionId) - } - }) - - if (this.orchestrators.size >= this.options.maxWorkers) { - debug?.('all orchestrators are ready, not creating more') - return this._promise - } - - // open the minimum amount of tabs - // if there is only 1 file running, we don't need 8 tabs running - const workerCount = Math.min( - this.options.maxWorkers - this.orchestrators.size, - files.length, - ) - - const promises: Promise[] = [] - for (let i = 0; i < workerCount; i++) { - const sessionId = crypto.randomUUID() - this.project.vitest._browserSessions.sessionIds.add(sessionId) - const project = this.project.name - debug?.('[%s] creating session for %s', sessionId, project) - const page = this.openPage(sessionId).then(() => { - // start running tests on the page when it's ready - this.runNextTest(method, sessionId) - }) - promises.push(page) - } - await Promise.all(promises) - debug?.('all sessions are created') - return this._promise - } - - private async openPage(sessionId: string) { - const sessionPromise = this.project.vitest._browserSessions.createSession( - sessionId, - this.project, - this, - ) - const browser = this.project.browser! - const url = new URL('/__vitest_test__/', this.options.origin) - url.searchParams.set('sessionId', sessionId) - const pagePromise = browser.provider.openPage( - sessionId, - url.toString(), - ) - await Promise.all([sessionPromise, pagePromise]) - } - - private getOrchestrator(sessionId: string) { - const orchestrator = this.orchestrators.get(sessionId) - if (!orchestrator) { - throw new Error(`Orchestrator not found for session ${sessionId}. This is a bug in Vitest. Please, open a new issue with reproduction.`) - } - return orchestrator - } - - private finishSession(sessionId: string): void { - this.readySessions.add(sessionId) - - // the last worker finished running tests - if (this.readySessions.size === this.orchestrators.size) { - this._promise?.resolve() - this._promise = undefined - debug?.('[%s] all tests finished running', sessionId) - } - else { - debug?.( - `did not finish sessions for ${sessionId}: |ready - %s| |overall - %s|`, - [...this.readySessions].join(', '), - [...this.orchestrators.keys()].join(', '), - ) - } - } - - private runNextTest(method: 'run' | 'collect', sessionId: string): void { - const file = this._queue.shift() - - if (!file) { - debug?.('[%s] no more tests to run', sessionId) - const isolate = this.project.config.browser.isolate - // we don't need to cleanup testers if isolation is enabled, - // because cleanup is done at the end of every test - if (isolate) { - this.finishSession(sessionId) - return - } - - // we need to cleanup testers first because there is only - // one iframe and it does the cleanup only after everything is completed - const orchestrator = this.getOrchestrator(sessionId) - orchestrator.cleanupTesters() - .catch(error => this.reject(error)) - .finally(() => this.finishSession(sessionId)) - return - } - - if (!this._promise) { - throw new Error(`Unexpected empty queue`) - } - const startTime = performance.now() - - const orchestrator = this.getOrchestrator(sessionId) - debug?.('[%s] run test %s', sessionId, file) - - this.setBreakpoint(sessionId, file).then(() => { - // this starts running tests inside the orchestrator - orchestrator.createTesters( - { - method, - files: [file], - // this will be parsed by the test iframe, not the orchestrator - // so we need to stringify it first to avoid double serialization - providedContext: this._providedContext || '[{}]', - startTime, - }, - ) - .then(() => { - debug?.('[%s] test %s finished running', sessionId, file) - this.runNextTest(method, sessionId) - }) - .catch((error) => { - // if user cancels the test run manually, ignore the error and exit gracefully - if ( - this.project.vitest.isCancelling - && error instanceof Error - && error.message.startsWith('Browser connection was closed while running tests') - ) { - this.cancel() - this._promise?.resolve() - this._promise = undefined - debug?.('[%s] browser connection was closed', sessionId) - return - } - debug?.('[%s] error during %s test run: %s', sessionId, file, error) - this.reject(error) - }) - }).catch(err => this.reject(err)) - } - - async setBreakpoint(sessionId: string, file: string) { - if (!this.project.config.inspector.waitForDebugger) { - return - } - - const provider = this.project.browser!.provider - const browser = this.project.config.browser.name - - if (shouldIgnoreDebugger(provider.name, browser)) { - debug?.('[$s] ignoring debugger in %s browser because it is not supported', sessionId, browser) - return - } - - if (!provider.getCDPSession) { - throw new Error('Unable to set breakpoint, CDP not supported') - } - - debug?.('[%s] set breakpoint for %s', sessionId, file) - const session = await provider.getCDPSession(sessionId) - await session.send('Debugger.enable', {}) - await session.send('Debugger.setBreakpointByUrl', { - lineNumber: 0, - urlRegex: escapePathToRegexp(file), - }) - } -} - -function shouldIgnoreDebugger(provider: string, browser: string) { - if (provider === 'webdriverio') { - return browser !== 'chrome' && browser !== 'edge' - } - return browser !== 'chromium' -} diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts index 4f11755ecd19..414ea765dbf0 100644 --- a/packages/browser/src/node/project.ts +++ b/packages/browser/src/node/project.ts @@ -66,6 +66,7 @@ export class ProjectBrowser implements IProjectBrowser { ReturnType >, ): void { + // TODO: register only for a specific project! don't override the global one because it's possible to have different providers in different projects if (!/^[a-z_$][\w$]*$/i.test(name)) { throw new Error( `Invalid command name "${name}". Only alphanumeric characters, $ and _ are allowed.`, diff --git a/packages/browser/src/node/utils.ts b/packages/browser/src/node/utils.ts index f61ed87fdc6c..faba097364d9 100644 --- a/packages/browser/src/node/utils.ts +++ b/packages/browser/src/node/utils.ts @@ -2,11 +2,13 @@ import type { BrowserProvider, BrowserProviderOption, ResolvedBrowserOptions, + ResolvedConfig, TestProject, } from 'vitest/node' import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' import { parseKeyDef as tlParse } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' +import { basename, dirname, relative, resolve } from 'pathe' declare enum DOM_KEY_LOCATION { STANDARD = 0, @@ -41,6 +43,28 @@ export function replacer(code: string, values: Record): string { return code.replace(/\{\s*(\w+)\s*\}/g, (_, key) => values[key] ?? _) } +export function resolveScreenshotPath( + testPath: string, + name: string, + config: ResolvedConfig, + customPath: string | undefined, +): string { + if (customPath) { + return resolve(dirname(testPath), customPath) + } + const dir = dirname(testPath) + const base = basename(testPath) + if (config.browser.screenshotDirectory) { + return resolve( + config.browser.screenshotDirectory, + relative(config.root, dir), + base, + name, + ) + } + return resolve(dir, '__screenshots__', base, name) +} + export async function getBrowserProvider( options: ResolvedBrowserOptions, project: TestProject, From a55dc59e7d3ef62b2af7da05763a0859896dc526 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 12:42:16 +0200 Subject: [PATCH 09/43] fix: remove old commands --- packages/browser/src/node/commands/clear.ts | 23 -- packages/browser/src/node/commands/click.ts | 79 ------- .../browser/src/node/commands/dragAndDrop.ts | 42 ---- packages/browser/src/node/commands/fill.ts | 24 -- packages/browser/src/node/commands/fs.ts | 2 +- packages/browser/src/node/commands/hover.ts | 21 -- packages/browser/src/node/commands/index.ts | 38 ---- .../browser/src/node/commands/keyboard.ts | 208 ------------------ .../browser/src/node/commands/screenshot.ts | 120 +--------- packages/browser/src/node/commands/select.ts | 51 ----- packages/browser/src/node/commands/tab.ts | 23 -- packages/browser/src/node/commands/trace.ts | 126 ----------- packages/browser/src/node/commands/type.ts | 62 ------ packages/browser/src/node/commands/upload.ts | 55 ----- .../browser/src/node/commands/viewport.ts | 14 -- 15 files changed, 3 insertions(+), 885 deletions(-) delete mode 100644 packages/browser/src/node/commands/clear.ts delete mode 100644 packages/browser/src/node/commands/click.ts delete mode 100644 packages/browser/src/node/commands/dragAndDrop.ts delete mode 100644 packages/browser/src/node/commands/fill.ts delete mode 100644 packages/browser/src/node/commands/hover.ts delete mode 100644 packages/browser/src/node/commands/keyboard.ts delete mode 100644 packages/browser/src/node/commands/select.ts delete mode 100644 packages/browser/src/node/commands/tab.ts delete mode 100644 packages/browser/src/node/commands/trace.ts delete mode 100644 packages/browser/src/node/commands/type.ts delete mode 100644 packages/browser/src/node/commands/upload.ts delete mode 100644 packages/browser/src/node/commands/viewport.ts diff --git a/packages/browser/src/node/commands/clear.ts b/packages/browser/src/node/commands/clear.ts deleted file mode 100644 index 79f26644be07..000000000000 --- a/packages/browser/src/node/commands/clear.ts +++ /dev/null @@ -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 = 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`) - } -} diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts deleted file mode 100644 index 31047a0138b6..000000000000 --- a/packages/browser/src/node/commands/click.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const click: UserEventCommand = async ( - context, - selector, - options = {}, -) => { - const provider = context.provider - if (provider instanceof PlaywrightBrowserProvider) { - const tester = context.iframe - await tester.locator(selector).click(options) - } - else if (provider instanceof WebdriverBrowserProvider) { - const browser = context.browser - await browser.$(selector).click(options as any) - } - else { - throw new TypeError(`Provider "${provider.name}" doesn't support click command`) - } -} - -export const dblClick: UserEventCommand = async ( - context, - selector, - options = {}, -) => { - const provider = context.provider - if (provider instanceof PlaywrightBrowserProvider) { - const tester = context.iframe - await tester.locator(selector).dblclick(options) - } - else if (provider instanceof WebdriverBrowserProvider) { - const browser = context.browser - await browser.$(selector).doubleClick() - } - else { - throw new TypeError(`Provider "${provider.name}" doesn't support dblClick command`) - } -} - -export const tripleClick: UserEventCommand = async ( - context, - selector, - options = {}, -) => { - const provider = context.provider - if (provider instanceof PlaywrightBrowserProvider) { - const tester = context.iframe - await tester.locator(selector).click({ - ...options, - clickCount: 3, - }) - } - else if (provider instanceof WebdriverBrowserProvider) { - 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() - } - else { - throw new TypeError(`Provider "${provider.name}" doesn't support tripleClick command`) - } -} diff --git a/packages/browser/src/node/commands/dragAndDrop.ts b/packages/browser/src/node/commands/dragAndDrop.ts deleted file mode 100644 index c665fd9ead10..000000000000 --- a/packages/browser/src/node/commands/dragAndDrop.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const dragAndDrop: UserEventCommand = async ( - context, - source, - target, - options_, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - const frame = await context.frame() - await frame.dragAndDrop( - source, - target, - options_, - ) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - 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() - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support dragging elements`) - } -} diff --git a/packages/browser/src/node/commands/fill.ts b/packages/browser/src/node/commands/fill.ts deleted file mode 100644 index c3256cccb682..000000000000 --- a/packages/browser/src/node/commands/fill.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const fill: UserEventCommand = async ( - context, - selector, - text, - options = {}, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - const { iframe } = context - const element = iframe.locator(selector) - await element.fill(text, options) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - const browser = context.browser - await browser.$(selector).setValue(text) - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support filling inputs`) - } -} diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts index 0c9970d1cce4..d9558106023f 100644 --- a/packages/browser/src/node/commands/fs.ts +++ b/packages/browser/src/node/commands/fs.ts @@ -1,5 +1,5 @@ +import type { BrowserCommands } from 'vitest/browser' import type { BrowserCommand, TestProject } from 'vitest/node' -import type { BrowserCommands } from '../../../context' import fs, { promises as fsp } from 'node:fs' import { basename, dirname, resolve } from 'node:path' import mime from 'mime/lite' diff --git a/packages/browser/src/node/commands/hover.ts b/packages/browser/src/node/commands/hover.ts deleted file mode 100644 index 7672eb839b75..000000000000 --- a/packages/browser/src/node/commands/hover.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const hover: UserEventCommand = async ( - context, - selector, - options = {}, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - await context.iframe.locator(selector).hover(options) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - const browser = context.browser - await browser.$(selector).moveTo(options as any) - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support hover`) - } -} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 4b4e02ba25ee..64e115b731a6 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -1,56 +1,18 @@ -import { clear } from './clear' -import { click, dblClick, tripleClick } from './click' -import { dragAndDrop } from './dragAndDrop' -import { fill } from './fill' import { _fileInfo, readFile, removeFile, writeFile, } from './fs' -import { hover } from './hover' -import { keyboard, keyboardCleanup } from './keyboard' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' -import { selectOptions } from './select' -import { tab } from './tab' -import { - annotateTraces, - deleteTracing, - startChunkTrace, - startTracing, - stopChunkTrace, -} from './trace' -import { type } from './type' -import { upload } from './upload' -import { viewport } from './viewport' export default { readFile: readFile as typeof readFile, removeFile: removeFile as typeof removeFile, writeFile: writeFile as typeof writeFile, - // private commands __vitest_fileInfo: _fileInfo as typeof _fileInfo, - __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_screenshot: screenshot as typeof screenshot, - __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, __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher, - __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, } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts deleted file mode 100644 index 6024e55ab074..000000000000 --- a/packages/browser/src/node/commands/keyboard.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { BrowserProvider } from 'vitest/node' -import type { UserEventCommand } from './utils' -import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' -import { parseKeyDef } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export interface KeyboardState { - unreleased: string[] -} - -export const keyboard: UserEventCommand<(text: string, state: KeyboardState) => Promise<{ unreleased: string[] }>> = async ( - context, - text, - state, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - const frame = await context.frame() - await frame.evaluate(focusIframe) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - await context.browser.execute(focusIframe) - } - - const pressed = new Set(state.unreleased) - - await keyboardImplementation( - pressed, - context.provider, - context.sessionId, - text, - async () => { - if (context.provider instanceof PlaywrightBrowserProvider) { - const frame = await context.frame() - await frame.evaluate(selectAll) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - await context.browser.execute(selectAll) - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support selecting all text`) - } - }, - true, - ) - - return { - unreleased: Array.from(pressed), - } -} - -export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise> = async ( - context, - state, -) => { - const { provider, sessionId } = context - if (!state.unreleased) { - return - } - if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(sessionId) - for (const key of state.unreleased) { - await page.keyboard.up(key) - } - } - else if (provider instanceof WebdriverBrowserProvider) { - const keyboard = provider.browser!.action('key') - for (const key of state.unreleased) { - keyboard.up(key) - } - await keyboard.perform() - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support keyboard api`) - } -} - -// 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, - provider: BrowserProvider, - sessionId: string, - text: string, - selectAll: () => Promise, - skipRelease: boolean, -): Promise<{ pressed: Set }> { - if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(sessionId) - const actions = parseKeyDef(defaultKeyMap, 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) - } - } - } - } - else if (provider instanceof WebdriverBrowserProvider) { - const { Key } = await import('webdriverio') - const browser = provider.browser! - const actions = parseKeyDef(defaultKeyMap, 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() - } -} diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index aaa279ab4859..76c349f105dc 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -1,11 +1,5 @@ -import type { BrowserCommand, BrowserCommandContext, ResolvedConfig } from 'vitest/node' -import type { ScreenshotOptions } from '../../../context' -import { mkdir, rm } from 'node:fs/promises' -import { normalize as platformNormalize } from 'node:path' -import { nanoid } from '@vitest/utils/helpers' -import { basename, dirname, normalize, relative, resolve } from 'pathe' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' +import type { ScreenshotOptions } from 'vitest/browser' +import type { BrowserCommand } from 'vitest/node' interface ScreenshotCommandOptions extends Omit { element?: string @@ -40,116 +34,6 @@ export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = as return returnResult(options, path, buffer) } -// TODO: remove this - -/** - * 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, -): Promise<{ buffer: Buffer; 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 }) - } - - if (context.provider instanceof PlaywrightBrowserProvider) { - 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 } - } - - if (context.provider instanceof WebdriverBrowserProvider) { - // webdriverio needs a path, so if one is not already set we create a temporary one - if (savePath === undefined) { - savePath = resolve(context.project.tmpDir, nanoid()) - - await mkdir(context.project.tmpDir, { recursive: true }) - } - - const page = context.provider.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 } - } - - throw new Error( - `Provider "${context.provider.name}" does not support screenshots`, - ) -} - -function resolveScreenshotPath( - testPath: string, - name: string, - config: ResolvedConfig, - customPath: string | undefined, -): string { - if (customPath) { - return resolve(dirname(testPath), customPath) - } - const dir = dirname(testPath) - const base = basename(testPath) - if (config.browser.screenshotDirectory) { - return resolve( - config.browser.screenshotDirectory, - relative(config.root, dir), - base, - name, - ) - } - return resolve(dir, '__screenshots__', base, name) -} - function returnResult( options: ScreenshotCommandOptions, path: string, diff --git a/packages/browser/src/node/commands/select.ts b/packages/browser/src/node/commands/select.ts deleted file mode 100644 index b2910a3176bb..000000000000 --- a/packages/browser/src/node/commands/select.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ElementHandle } from 'playwright' -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const selectOptions: UserEventCommand = async ( - context, - selector, - userValues, - options = {}, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - 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) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - 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') - } - } - else { - throw new TypeError(`Provider "${context.provider.name}" doesn't support selectOptions command`) - } -} diff --git a/packages/browser/src/node/commands/tab.ts b/packages/browser/src/node/commands/tab.ts deleted file mode 100644 index 3739841b9aca..000000000000 --- a/packages/browser/src/node/commands/tab.ts +++ /dev/null @@ -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 tab: UserEventCommand = async ( - context, - options = {}, -) => { - const provider = context.provider - if (provider instanceof PlaywrightBrowserProvider) { - const page = context.page - await page.keyboard.press(options.shift === true ? 'Shift+Tab' : 'Tab') - return - } - if (provider instanceof WebdriverBrowserProvider) { - const { Key } = await import('webdriverio') - const browser = context.browser - await browser.keys(options.shift === true ? [Key.Shift, Key.Tab] : [Key.Tab]) - return - } - throw new Error(`Provider "${provider.name}" doesn't support tab command`) -} diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts deleted file mode 100644 index 0db3a32adcc6..000000000000 --- a/packages/browser/src/node/commands/trace.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { BrowserCommandContext } from 'vitest/node' -import type { BrowserCommand } from '../plugin' -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 (provider.tracingContexts.has(sessionId)) { - return - } - - provider.tracingContexts.add(sessionId) - const options = project.config.browser!.trace - await context.tracing.start({ - screenshots: options.screenshots ?? true, - snapshots: options.snapshots ?? true, - // currently, PW shows sources in private methods - sources: false, - }).catch(() => { - provider.tracingContexts.delete(sessionId) - }) - return - } - throw new TypeError(`The ${provider.name} provider does not support tracing.`) -} - -export const startChunkTrace: BrowserCommand<[{ name: string; title: string }]> = async ( - command, - { name, title }, -) => { - const { provider, sessionId, testPath, context } = command - if (!testPath) { - throw new Error(`stopChunkTrace cannot be called outside of the test file.`) - } - if (provider instanceof PlaywrightBrowserProvider) { - if (!provider.tracingContexts.has(sessionId)) { - await startTracing(command) - } - const path = resolveTracesPath(command, name) - provider.pendingTraces.set(path, sessionId) - await context.tracing.startChunk({ name, title }) - return - } - throw new TypeError(`The ${provider.name} provider does not support tracing.`) -} - -export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async ( - context, - { name }, -) => { - if (context.provider instanceof PlaywrightBrowserProvider) { - const path = resolveTracesPath(context, name) - context.provider.pendingTraces.delete(path) - await context.context.tracing.stopChunk({ path }) - return { tracePath: path } - } - throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) -} - -function resolveTracesPath({ testPath, project }: BrowserCommandContext, name: string) { - if (!testPath) { - throw new Error(`This command can only be called inside a test file.`) - } - const options = project.config.browser!.trace - const sanitizedName = `${project.name.replace(/[^a-z0-9]/gi, '-')}-${name}.trace.zip` - if (options.tracesDir) { - return resolve(options.tracesDir, sanitizedName) - } - const dir = dirname(testPath) - const base = basename(testPath) - return resolve( - dir, - '__traces__', - base, - `${project.name.replace(/[^a-z0-9]/gi, '-')}-${name}.trace.zip`, - ) -} - -export const deleteTracing: BrowserCommand<[{ traces: string[] }]> = async ( - context, - { traces }, -) => { - if (!context.testPath) { - throw new Error(`stopChunkTrace cannot be called outside of the test file.`) - } - if (context.provider instanceof PlaywrightBrowserProvider) { - return Promise.all( - traces.map(trace => unlink(trace).catch((err) => { - if (err.code === 'ENOENT') { - // Ignore the error if the file doesn't exist - return - } - // Re-throw other errors - throw err - })), - ) - } - - throw new Error(`provider ${context.provider.name} is not supported`) -} - -export const annotateTraces: BrowserCommand<[{ traces: string[]; testId: string }]> = async ( - { project }, - { testId, traces }, -) => { - const vitest = project.vitest - await Promise.all(traces.map((trace) => { - const entity = vitest.state.getReportedEntityById(testId) - return vitest._testRun.annotate(testId, { - message: relative(project.config.root, trace), - type: 'traces', - attachment: { - path: trace, - contentType: 'application/octet-stream', - }, - location: entity?.location - ? { - file: entity.module.moduleId, - line: entity.location.line, - column: entity.location.column, - } - : undefined, - }) - })) -} diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts deleted file mode 100644 index 189e7343b280..000000000000 --- a/packages/browser/src/node/commands/type.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { UserEvent } from '../../../context' -import type { UserEventCommand } from './utils' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' -import { keyboardImplementation } from './keyboard' - -export const type: UserEventCommand = async ( - context, - selector, - text, - options = {}, -) => { - const { skipClick = false, skipAutoClose = false } = options - const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? []) - - if (context.provider instanceof PlaywrightBrowserProvider) { - const { iframe } = context - const element = iframe.locator(selector) - - if (!skipClick) { - await element.focus() - } - - await keyboardImplementation( - unreleased, - context.provider, - context.sessionId, - text, - () => element.selectText(), - skipAutoClose, - ) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - 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, - ) - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support typing`) - } - - return { - unreleased: Array.from(unreleased), - } -} diff --git a/packages/browser/src/node/commands/upload.ts b/packages/browser/src/node/commands/upload.ts deleted file mode 100644 index 0893fa37a26e..000000000000 --- a/packages/browser/src/node/commands/upload.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { UserEventUploadOptions } from '@vitest/browser/context' -import type { UserEventCommand } from './utils' -import { resolve } from 'pathe' -import { PlaywrightBrowserProvider } from '../providers/playwright' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const upload: UserEventCommand<(element: string, files: Array, 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 - - if (context.provider instanceof PlaywrightBrowserProvider) { - 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) - } - else if (context.provider instanceof WebdriverBrowserProvider) { - 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) - } - } - else { - throw new TypeError(`Provider "${context.provider.name}" does not support uploading files via userEvent.upload`) - } -} diff --git a/packages/browser/src/node/commands/viewport.ts b/packages/browser/src/node/commands/viewport.ts deleted file mode 100644 index a117275d3fde..000000000000 --- a/packages/browser/src/node/commands/viewport.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { UserEventCommand } from './utils' -import { WebdriverBrowserProvider } from '../providers/webdriverio' - -export const viewport: UserEventCommand<(options: { - width: number - height: number -}) => void> = async (context, options) => { - if (context.provider instanceof WebdriverBrowserProvider) { - await context.provider.setViewport(options) - } - else { - throw new TypeError(`Provider ${context.provider.name} doesn't support "viewport" command`) - } -} From 4b02284949139421052a72e255377773ebf461d8 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 12:51:36 +0200 Subject: [PATCH 10/43] fix: make commands local --- .../node/commands/screenshotMatcher/utils.ts | 7 +++---- packages/browser/src/node/project.ts | 20 +++++++++++++++++-- packages/browser/src/node/rpc.ts | 16 +++++++++------ packages/vitest/src/node/types/browser.ts | 5 +++++ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/node/commands/screenshotMatcher/utils.ts b/packages/browser/src/node/commands/screenshotMatcher/utils.ts index ae2bf7489ca8..37da8e08ff88 100644 --- a/packages/browser/src/node/commands/screenshotMatcher/utils.ts +++ b/packages/browser/src/node/commands/screenshotMatcher/utils.ts @@ -1,11 +1,10 @@ +import type { ScreenshotMatcherOptions } from 'vitest/browser' import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node' -import type { ScreenshotMatcherOptions } from '../../../../context' import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types' import type { AnyCodec } from './codecs' import { platform } from 'node:os' import { deepMerge } from '@vitest/utils/helpers' import { basename, dirname, extname, join, relative, resolve } from 'pathe' -import { takeScreenshot } from '../screenshot' import { getCodec } from './codecs' import { getComparator } from './comparators' @@ -238,8 +237,8 @@ export function takeDecodedScreenshot({ name: string screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] }): ReturnType { - return takeScreenshot( - context, + return context.triggerCommand( + '__vitest_takeScreenshot', name, { ...screenshotOptions, save: false, element }, ).then( diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts index 414ea765dbf0..2362a1560b2e 100644 --- a/packages/browser/src/node/project.ts +++ b/packages/browser/src/node/project.ts @@ -4,6 +4,7 @@ import type { ParsedStack, SerializedConfig, TestError } from 'vitest' import type { BrowserCommands } from 'vitest/browser' import type { BrowserCommand, + BrowserCommandContext, BrowserProvider, ProjectBrowser as IProjectBrowser, ResolvedConfig, @@ -59,6 +60,8 @@ export class ProjectBrowser implements IProjectBrowser { return this.parent.vite } + private commands = {} as Record> + public registerCommand( name: K, cb: BrowserCommand< @@ -66,13 +69,26 @@ export class ProjectBrowser implements IProjectBrowser { ReturnType >, ): void { - // TODO: register only for a specific project! don't override the global one because it's possible to have different providers in different projects if (!/^[a-z_$][\w$]*$/i.test(name)) { throw new Error( `Invalid command name "${name}". Only alphanumeric characters, $ and _ are allowed.`, ) } - this.parent.commands[name] = cb + this.commands[name] = cb + } + + public triggerCommand( + name: K, + context: BrowserCommandContext, + ...args: Parameters + ): ReturnType { + if (name in this.commands) { + return this.commands[name](context, ...args) + } + if (name in this.parent.commands) { + return this.parent.commands[name](context, ...args) + } + throw new Error(`Provider ${this.provider.name} does not support command "${name}".`) } wrapSerializedConfig(): SerializedConfig { diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index f008c9030d19..e8bea332d1ee 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -240,10 +240,6 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke if (!provider) { throw new Error('Commands are only available for browser tests.') } - const commands = globalServer.commands - if (!commands || !commands[command]) { - throw new Error(`Provider ${provider.name} does not support command "${command}".`) - } const context = Object.assign( { testPath, @@ -252,12 +248,20 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke contextId: sessionId, sessionId, triggerCommand: (name: string, ...args: any[]) => { - return commands[name](context, ...args) + return project.browser!.triggerCommand( + name as any, + context, + ...args, + ) }, }, provider.getCommandsContext(sessionId), ) as any as BrowserCommandContext - return await commands[command](context, ...payload) + return await project.browser!.triggerCommand( + command as any, + context, + ...payload, + ) }, resolveMock(rawId, importer, options) { return mockResolver.resolveMock(rawId, importer, options) diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index ccb4de6df3ac..8e23f2fa5dd4 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -320,6 +320,11 @@ export interface ProjectBrowser { ReturnType > ) => void + triggerCommand: ( + name: K, + context: BrowserCommandContext, + ...args: Parameters + ) => ReturnType } export interface BrowserCommand { From 37475ae5f1f422c61fd13f7e849e9397eef07b32 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 15:35:58 +0200 Subject: [PATCH 11/43] feat: move locators --- packages/browser-playwright/rollup.config.js | 8 +-- packages/browser-playwright/src/constants.ts | 5 ++ .../src/locators.ts} | 13 +++-- packages/browser-playwright/src/playwright.ts | 6 +++ packages/browser-preview/README.md | 49 +++++++++++++++++++ packages/browser-preview/rollup.config.js | 10 ++-- packages/browser-preview/src/constants.ts | 5 ++ .../src/locators.ts} | 12 +++-- packages/browser-preview/src/preview.ts | 6 +++ packages/browser-webdriverio/rollup.config.js | 10 ++-- .../src/commands/keyboard.ts | 1 - packages/browser-webdriverio/src/constants.ts | 5 ++ .../src/locators.ts} | 22 +++++---- .../browser-webdriverio/src/webdriverio.ts | 6 +++ packages/browser/package.json | 30 +++--------- packages/browser/rollup.config.js | 20 +------- packages/browser/src/client/tester/context.ts | 10 ++-- .../src/client/tester/expect-element.ts | 2 +- .../client/tester/expect/toMatchScreenshot.ts | 2 +- .../src/client/tester/locators/index.ts | 16 ++++-- packages/browser/src/client/tester/runner.ts | 2 +- .../tester/{utils.ts => tester-utils.ts} | 10 +++- packages/browser/src/client/tester/tester.ts | 2 +- packages/browser/src/client/utils.ts | 2 +- packages/browser/src/node/plugin.ts | 20 ++++---- .../browser/src/node/plugins/pluginContext.ts | 1 + packages/browser/src/node/project.ts | 16 ++++-- packages/browser/src/node/projectParent.ts | 7 +-- packages/browser/src/node/rpc.ts | 7 ++- packages/browser/src/node/utils.ts | 18 ++----- .../src/node/projects/resolveProjects.ts | 26 +++------- packages/vitest/src/node/types/browser.ts | 16 +++++- pnpm-lock.yaml | 21 ++------ 33 files changed, 215 insertions(+), 171 deletions(-) create mode 100644 packages/browser-playwright/src/constants.ts rename packages/{browser/src/client/tester/locators/playwright.ts => browser-playwright/src/locators.ts} (95%) create mode 100644 packages/browser-preview/README.md create mode 100644 packages/browser-preview/src/constants.ts rename packages/{browser/src/client/tester/locators/preview.ts => browser-preview/src/locators.ts} (91%) create mode 100644 packages/browser-webdriverio/src/constants.ts rename packages/{browser/src/client/tester/locators/webdriverio.ts => browser-webdriverio/src/locators.ts} (92%) rename packages/browser/src/client/tester/{utils.ts => tester-utils.ts} (96%) diff --git a/packages/browser-playwright/rollup.config.js b/packages/browser-playwright/rollup.config.js index 4a5ab941916b..aa8f975247c3 100644 --- a/packages/browser-playwright/rollup.config.js +++ b/packages/browser-playwright/rollup.config.js @@ -13,9 +13,6 @@ const external = [ ...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies || {}), /^@?vitest(\/|$)/, - '@vitest/browser/utils', - 'worker_threads', - 'node:worker_threads', 'vite', 'playwright-core/types/protocol', ] @@ -36,7 +33,10 @@ const plugins = [ export default () => defineConfig([ { - input: './src/index.ts', + input: { + index: './src/index.ts', + locators: './src/locators.ts', + }, output: { dir: 'dist', format: 'esm', diff --git a/packages/browser-playwright/src/constants.ts b/packages/browser-playwright/src/constants.ts new file mode 100644 index 000000000000..7229579f4e97 --- /dev/null +++ b/packages/browser-playwright/src/constants.ts @@ -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') diff --git a/packages/browser/src/client/tester/locators/playwright.ts b/packages/browser-playwright/src/locators.ts similarity index 95% rename from packages/browser/src/client/tester/locators/playwright.ts rename to packages/browser-playwright/src/locators.ts index 631bb0861f42..eee066ae985a 100644 --- a/packages/browser/src/client/tester/locators/playwright.ts +++ b/packages/browser-playwright/src/locators.ts @@ -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,9 +15,12 @@ import { getByTestIdSelector, getByTextSelector, getByTitleSelector, -} from 'ivya' -import { getIframeScale, processTimeoutOptions } from '../utils' -import { Locator, selectorEngine } from './index' + getIframeScale, + Locator, + processTimeoutOptions, + selectorEngine, +} from '@vitest/browser/locators' +import { page, server } from 'vitest/browser' page.extend({ getByLabelText(text, options) { @@ -43,6 +45,7 @@ page.extend({ return new PlaywrightLocator(getByTitleSelector(title, options)) }, + // @ts-expect-error _createLocator is private _createLocator(selector: string) { return new PlaywrightLocator(selector) }, diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 4529ac3ba2f9..d5100bd26344 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -28,9 +28,11 @@ import type { } from 'vitest/node' import { createBrowserServer } 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') @@ -99,6 +101,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider { public tracingContexts: Set = new Set() public pendingTraces: Map = new Map() + public initScripts: string[] = [ + resolve(distRoot, 'locators.js'), + ] + constructor( private project: TestProject, private options: PlaywrightProviderOptions, diff --git a/packages/browser-preview/README.md b/packages/browser-preview/README.md new file mode 100644 index 000000000000..c1e0104fcf10 --- /dev/null +++ b/packages/browser-preview/README.md @@ -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/) diff --git a/packages/browser-preview/rollup.config.js b/packages/browser-preview/rollup.config.js index 4a5ab941916b..2e6d3bcf85a3 100644 --- a/packages/browser-preview/rollup.config.js +++ b/packages/browser-preview/rollup.config.js @@ -13,11 +13,6 @@ const external = [ ...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies || {}), /^@?vitest(\/|$)/, - '@vitest/browser/utils', - 'worker_threads', - 'node:worker_threads', - 'vite', - 'playwright-core/types/protocol', ] const dtsUtils = createDtsUtils() @@ -36,7 +31,10 @@ const plugins = [ export default () => defineConfig([ { - input: './src/index.ts', + input: { + index: './src/index.ts', + locators: './src/locators.ts', + }, output: { dir: 'dist', format: 'esm', diff --git a/packages/browser-preview/src/constants.ts b/packages/browser-preview/src/constants.ts new file mode 100644 index 000000000000..7229579f4e97 --- /dev/null +++ b/packages/browser-preview/src/constants.ts @@ -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') diff --git a/packages/browser/src/client/tester/locators/preview.ts b/packages/browser-preview/src/locators.ts similarity index 91% rename from packages/browser/src/client/tester/locators/preview.ts rename to packages/browser-preview/src/locators.ts index 63a41179659f..12b5fd032f8d 100644 --- a/packages/browser/src/client/tester/locators/preview.ts +++ b/packages/browser-preview/src/locators.ts @@ -1,5 +1,5 @@ -import { page, server, userEvent } from '@vitest/browser/context' import { + convertElementToCssSelector, getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, @@ -7,10 +7,11 @@ import { getByTestIdSelector, getByTextSelector, getByTitleSelector, -} from 'ivya' -import { getElementError } from '../public-utils' -import { convertElementToCssSelector } from '../utils' -import { Locator, selectorEngine } from './index' + Locator, + selectorEngine, +} from '@vitest/browser/locators' +import { getElementError } from '@vitest/browser/utils' +import { page, server, userEvent } from 'vitest/browser' page.extend({ getByLabelText(text, options) { @@ -35,6 +36,7 @@ page.extend({ return new PreviewLocator(getByTitleSelector(title, options)) }, + // @ts-expect-error _createLocator is private _createLocator(selector: string) { return new PreviewLocator(selector) }, diff --git a/packages/browser-preview/src/preview.ts b/packages/browser-preview/src/preview.ts index 5956f4906240..c21989f99af0 100644 --- a/packages/browser-preview/src/preview.ts +++ b/packages/browser-preview/src/preview.ts @@ -1,5 +1,7 @@ import type { BrowserProvider, BrowserProviderOption, TestProject } from 'vitest/node' import { createBrowserServer } from '@vitest/browser' +import { resolve } from 'pathe' +import { distRoot } from './constants' export function preview(): BrowserProviderOption { return { @@ -18,6 +20,10 @@ export class PreviewBrowserProvider implements BrowserProvider { private project!: TestProject private open = false + public initScripts: string[] = [ + resolve(distRoot, 'locators.js'), + ] + constructor(project: TestProject) { this.project = project this.open = false diff --git a/packages/browser-webdriverio/rollup.config.js b/packages/browser-webdriverio/rollup.config.js index 4a5ab941916b..2e6d3bcf85a3 100644 --- a/packages/browser-webdriverio/rollup.config.js +++ b/packages/browser-webdriverio/rollup.config.js @@ -13,11 +13,6 @@ const external = [ ...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies || {}), /^@?vitest(\/|$)/, - '@vitest/browser/utils', - 'worker_threads', - 'node:worker_threads', - 'vite', - 'playwright-core/types/protocol', ] const dtsUtils = createDtsUtils() @@ -36,7 +31,10 @@ const plugins = [ export default () => defineConfig([ { - input: './src/index.ts', + input: { + index: './src/index.ts', + locators: './src/locators.ts', + }, output: { dir: 'dist', format: 'esm', diff --git a/packages/browser-webdriverio/src/commands/keyboard.ts b/packages/browser-webdriverio/src/commands/keyboard.ts index 1af975c925b5..d5b3b5a6a177 100644 --- a/packages/browser-webdriverio/src/commands/keyboard.ts +++ b/packages/browser-webdriverio/src/commands/keyboard.ts @@ -1,7 +1,6 @@ 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' diff --git a/packages/browser-webdriverio/src/constants.ts b/packages/browser-webdriverio/src/constants.ts new file mode 100644 index 000000000000..7229579f4e97 --- /dev/null +++ b/packages/browser-webdriverio/src/constants.ts @@ -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') diff --git a/packages/browser/src/client/tester/locators/webdriverio.ts b/packages/browser-webdriverio/src/locators.ts similarity index 92% rename from packages/browser/src/client/tester/locators/webdriverio.ts rename to packages/browser-webdriverio/src/locators.ts index f80fbb132dc0..e3972c509e8d 100644 --- a/packages/browser/src/client/tester/locators/webdriverio.ts +++ b/packages/browser-webdriverio/src/locators.ts @@ -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,11 +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' + getIframeScale, + Locator, + selectorEngine, +} from '@vitest/browser/locators' +import { getElementError } from '@vitest/browser/utils' +import { page, server } from 'vitest/browser' page.extend({ getByLabelText(text, options) { @@ -42,6 +43,7 @@ page.extend({ return new WebdriverIOLocator(getByTitleSelector(title, options)) }, + // @ts-expect-error this is a private property _createLocator(selector: string) { return new WebdriverIOLocator(selector) }, @@ -151,7 +153,7 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[] function processClickOptions(options_?: UserEventClickOptions) { // only ui scales the iframe, so we need to adjust the position - if (!options_ || !getBrowserState().config.browser.ui) { + if (!options_ || !server.config.browser.ui) { return options_ } const options = options_ as import('webdriverio').ClickOptions @@ -169,7 +171,7 @@ function processClickOptions(options_?: UserEventClickOptions) { function processHoverOptions(options_?: UserEventHoverOptions) { // only ui scales the iframe, so we need to adjust the position - if (!options_ || !getBrowserState().config.browser.ui) { + if (!options_ || !server.config.browser.ui) { return options_ } const options = options_ as import('webdriverio').MoveToOptions @@ -185,7 +187,7 @@ function processHoverOptions(options_?: UserEventHoverOptions) { function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) { // only ui scales the iframe, so we need to adjust the position - if (!options_ || !getBrowserState().config.browser.ui) { + if (!options_ || !server.config.browser.ui) { return options_ } const cache = {} diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 0cb17d9e2fae..72fca38d6e9e 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -13,8 +13,10 @@ import type { import type { ClickOptions, DragAndDropOptions, remote } from 'webdriverio' import { createBrowserServer } 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') @@ -52,6 +54,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 } diff --git a/packages/browser/package.json b/packages/browser/package.json index ba9aa58c2f86..197843f31205 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -31,9 +31,9 @@ "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", @@ -62,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:", @@ -90,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:*" } } diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 780c28294aca..e4f1b248cd8c 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -71,10 +71,7 @@ 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', }, @@ -146,7 +143,7 @@ export default () => }, { input: dtsUtilsClient.dtsInput({ - 'locators/index': './src/client/tester/locators/index.ts', + locators: './src/client/tester/locators/index.ts', }), output: { dir: 'dist', @@ -157,17 +154,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, - // }), - // ], - // }, ]) diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index c640ca61acc6..2471574f6d09 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -2,19 +2,19 @@ import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent, } from '@testing-library/user-event' +import type { RunnerTask } from 'vitest' import type { BrowserLocators, BrowserPage, Locator, UserEvent, -} from '@vitest/browser/context' -import type { RunnerTask } from 'vitest' +} from 'vitest/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 { 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 +38,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' @@ -389,7 +389,7 @@ export const locators: BrowserLocators = { _extendedMethods: new Set(), } -declare module '@vitest/browser/context' { +declare module 'vitest/browser' { interface BrowserPage { /** @internal */ _createLocator: (selector: string) => Locator diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 903e87273779..e86ae304cc66 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -3,7 +3,7 @@ import type { ExpectPollOptions, PromisifyDomAssertion } from 'vitest' 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') diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index 5e439499db0d..e30898f365a3 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -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([]) diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index fd7ecf0a7a22..ecdba9ae3635 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -21,11 +21,21 @@ import { getByTextSelector, getByTitleSelector, Ivya, - } from 'ivya' 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' // we prefer using playwright locators because they are more powerful and support Shadow DOM export const selectorEngine: Ivya = Ivya.create({ @@ -125,7 +135,7 @@ export abstract class Locator { ): Promise { 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 diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index b3dfd2c61aed..9e36352b070d 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -11,7 +11,7 @@ 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' diff --git a/packages/browser/src/client/tester/utils.ts b/packages/browser/src/client/tester/tester-utils.ts similarity index 96% rename from packages/browser/src/client/tester/utils.ts rename to packages/browser/src/client/tester/tester-utils.ts index e3702d4b7b30..6233c96efa7c 100644 --- a/packages/browser/src/client/tester/utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -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) +} diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index e542e803328e..1fa6aa16a14a 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -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' diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index fc965e1c8b26..ee72e460680b 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -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 { const name = `/@id/${id}`.replace(/\\/g, '/') diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 30091fa0899b..f65aa2ce2b46 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -8,7 +8,7 @@ import { createRequire } from 'node:module' import { dynamicImportPlugin } from '@vitest/mocker/node' import { toArray } from '@vitest/utils/helpers' import MagicString from 'magic-string' -import { basename, dirname, extname, resolve } from 'pathe' +import { basename, dirname, extname, join, resolve } from 'pathe' import sirv from 'sirv' import { coverageConfigDefaults } from 'vitest/config' import { @@ -549,16 +549,14 @@ body { }, injectTo: 'head' as const, }, - parentServer.locatorsUrl - ? { - tag: 'script', - attrs: { - type: 'module', - src: parentServer.locatorsUrl, - }, - injectTo: 'head', - } as const - : null, + ...parentServer.initScripts.map(script => ({ + tag: 'script', + attrs: { + type: 'module', + src: join('/@fs/', script), + }, + injectTo: 'head', + } as const)), ...testerTags, ].filter(s => s != null) }, diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index cb266f701561..51a98eb80e6a 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -72,6 +72,7 @@ async function getUserEventImport(provider: string, resolve: (id: string, import if (provider !== 'preview') { return 'const _userEventSetup = undefined' } + // TODO: resolve relative to @vitest/browser-preview const resolved = await resolve('@testing-library/user-event', __dirname) if (!resolved) { throw new Error(`Failed to resolve user-event package from ${__dirname}`) diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts index 2362a1560b2e..a882a0ea4236 100644 --- a/packages/browser/src/node/project.ts +++ b/packages/browser/src/node/project.ts @@ -77,11 +77,11 @@ export class ProjectBrowser implements IProjectBrowser { this.commands[name] = cb } - public triggerCommand( + public triggerCommand = (( name: K, context: BrowserCommandContext, ...args: Parameters - ): ReturnType { + ): ReturnType => { if (name in this.commands) { return this.commands[name](context, ...args) } @@ -89,7 +89,7 @@ export class ProjectBrowser implements IProjectBrowser { return this.parent.commands[name](context, ...args) } throw new Error(`Provider ${this.provider.name} does not support command "${name}".`) - } + }) as any wrapSerializedConfig(): SerializedConfig { const config = wrapConfig(this.project.serializedConfig) @@ -103,6 +103,16 @@ export class ProjectBrowser implements IProjectBrowser { return } this.provider = await getBrowserProvider(project.config.browser, project) + if (this.provider.initScripts) { + this.parent.initScripts = this.provider.initScripts + // make sure the script can be imported + this.provider.initScripts.forEach((script) => { + const allow = this.parent.vite.config.server.fs.allow + if (!allow.includes(script)) { + allow.push(script) + } + }) + } } public parseErrorStacktrace( diff --git a/packages/browser/src/node/projectParent.ts b/packages/browser/src/node/projectParent.ts index 699bce4ec2f2..9c46f75a28d3 100644 --- a/packages/browser/src/node/projectParent.ts +++ b/packages/browser/src/node/projectParent.ts @@ -38,6 +38,8 @@ export class ParentBrowserProject { public matchersUrl: string public stateJs: Promise | string + public initScripts: string[] = [] + public commands: Record> = {} public children: Set = new Set() public vitest: Vitest @@ -129,11 +131,6 @@ export class ParentBrowserProject { ).then(js => (this.injectorJs = js)) this.errorCatcherUrl = join('/@fs/', resolve(distRoot, 'client/error-catcher.js')) - const builtinProviders = ['playwright', 'webdriverio', 'preview'] - const providerName = project.config.browser.provider?.name || 'preview' - if (builtinProviders.includes(providerName)) { - this.locatorsUrl = join('/@fs/', distRoot, 'locators', `${providerName}.js`) - } this.matchersUrl = join('/@fs/', distRoot, 'expect-element.js') this.stateJs = readFile( resolve(distRoot, 'state.js'), diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index e8bea332d1ee..b6d24fb65de4 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -5,7 +5,6 @@ import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProj import type { WebSocket } from 'ws' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' import type { ParentBrowserProject } from './projectParent' -import type { WebdriverBrowserProvider } from './providers/webdriverio' import type { BrowserServerState } from './state' import { existsSync, promises as fs } from 'node:fs' import { AutomockedModule, AutospiedModule, ManualMockedModule, RedirectedModule } from '@vitest/mocker' @@ -220,7 +219,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke return vitest.state.getCountOfFailedTests() }, async wdioSwitchContext(direction) { - const provider = project.browser!.provider as WebdriverBrowserProvider + const provider = project.browser!.provider if (!provider) { throw new Error('Commands are only available for browser tests.') } @@ -228,10 +227,10 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke throw new Error('Switch context is only available for WebDriverIO provider.') } if (direction === 'iframe') { - await provider.switchToTestFrame() + await (provider as any).switchToTestFrame() } else { - await provider.switchToMainFrame() + await (provider as any).switchToMainFrame() } }, async triggerCommand(sessionId, command, testPath, payload) { diff --git a/packages/browser/src/node/utils.ts b/packages/browser/src/node/utils.ts index faba097364d9..131c640f14aa 100644 --- a/packages/browser/src/node/utils.ts +++ b/packages/browser/src/node/utils.ts @@ -1,6 +1,5 @@ import type { BrowserProvider, - BrowserProviderOption, ResolvedBrowserOptions, ResolvedConfig, TestProject, @@ -76,19 +75,8 @@ export async function getBrowserProvider( `${name}Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`, ) } - if ( - // nothing is provided by default - options.provider == null - // the provider is provided via `--browser.provider=playwright` - // or the config was serialized, but we can infer the factory by the name - || ('_cli' in options.provider && typeof options.provider.providerFactory !== 'function') - ) { - const providers = await import('./providers/index') - const name = (options.provider?.name || 'preview') as 'preview' | 'webdriverio' | 'playwright' - if (!(name in providers)) { - throw new Error(`Unknown browser provider "${name}". Available providers: ${Object.keys(providers).join(', ')}.`) - } - return (providers[name] as (options?: object) => BrowserProviderOption)(options.provider?.options).providerFactory(project) + if (options.provider == null) { + throw new Error(`Browser Mode requires the "provider" to always be specified.`) } const supportedBrowsers = options.provider.supportedBrowser || [] if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { @@ -99,7 +87,7 @@ export async function getBrowserProvider( ) } if (typeof options.provider.providerFactory !== 'function') { - throw new TypeError(`The "${name}" browser provider does not provide a "factory" function. Received ${typeof options.provider.providerFactory}.`) + throw new TypeError(`The "${name}" browser provider does not provide a "providerFactory" function. Received ${typeof options.provider.providerFactory}.`) } return options.provider.providerFactory(project) } diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index 1f08dd168009..eac7449c64cb 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -18,7 +18,6 @@ import { configFiles as defaultConfigFiles } from '../../constants' import { isTTY } from '../../utils/env' import { VitestFilteredOutProjectError } from '../errors' import { initializeProject, TestProject } from '../project' -import { withLabel } from '../reporters/renderers/utils' // vitest.config.* // vite.config.* @@ -190,24 +189,10 @@ export async function resolveBrowserProjects( return } const instances = project.config.browser.instances || [] - const browser = project.config.browser.name - if (instances.length === 0 && browser) { - instances.push({ - browser, - name: project.name ? `${project.name} (${browser})` : browser, - }) - vitest.logger.warn( - withLabel( - 'yellow', - 'Vitest', - [ - `No browser "instances" were defined`, - project.name ? ` for the "${project.name}" project. ` : '. ', - `Running tests in "${project.config.browser.name}" browser. `, - 'The "browser.name" field is deprecated since Vitest 3. ', - 'Read more: https://vitest.dev/guide/browser/config#browser-instances', - ].filter(Boolean).join(''), - ), + if (instances.length === 0) { + throw new Error( + `No browser "instances" were defined${ + project.name ? ` for the "${project.name}" project.` : ''}`, ) } const originalName = project.config.name @@ -237,6 +222,9 @@ export async function resolveBrowserProjects( if (name == null) { throw new Error(`The browser configuration must have a "name" property. This is a bug in Vitest. Please, open a new issue with reproduction`) } + if (config.provider?.name !== project.config.browser.provider?.name) { + throw new Error(`The instance cannot have a different provider from its parent. The "${name}" instance specifies "${config.provider?.name}" provider, but its parent has a "${project.config.browser.provider?.name}" provider.`) + } if (names.has(name)) { throw new Error( diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 8e23f2fa5dd4..3a314e9a669b 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -44,6 +44,7 @@ export interface BrowserServerFactory { export interface BrowserProvider { name: string mocker?: BrowserModuleMocker + readonly initScripts?: string[] /** * @experimental opt-in into file parallelisation */ @@ -85,7 +86,6 @@ export interface BrowserInstanceOption extends | 'testerHtmlPath' | 'screenshotDirectory' | 'screenshotFailures' - | 'provider' > { /** * Name of the browser @@ -93,6 +93,7 @@ export interface BrowserInstanceOption extends browser: string name?: string + provider?: BrowserProviderOption } export interface BrowserConfigOptions { @@ -117,8 +118,19 @@ export interface BrowserConfigOptions { /** * Browser provider + * @example + * ```ts + * import { playwright } from '@vitest/browser-playwright' + * export default defineConfig({ + * test: { + * browser: { + * provider: playwright(), + * }, + * }, + * }) + * ``` */ - provider?: BrowserProviderOption + provider: BrowserProviderOption /** * enable headless mode diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bb4e9081954..75ac748c92c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,12 +441,6 @@ importers: packages/browser: dependencies: - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) '@vitest/mocker': specifier: workspace:* version: link:../mocker @@ -472,6 +466,9 @@ importers: specifier: 'catalog:' version: 8.18.3 devDependencies: + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/pngjs': specifier: ^6.0.5 version: 6.0.5 @@ -481,9 +478,6 @@ importers: '@vitest/runner': specifier: workspace:* version: link:../runner - '@wdio/types': - specifier: ^9.19.2 - version: 9.19.2 birpc: specifier: 'catalog:' version: 2.5.0 @@ -499,18 +493,9 @@ importers: pathe: specifier: 'catalog:' version: 2.0.3 - playwright: - specifier: ^1.55.0 - version: 1.55.0 - playwright-core: - specifier: ^1.55.0 - version: 1.55.0 vitest: specifier: workspace:* version: link:../vitest - webdriverio: - specifier: ^9.19.2 - version: 9.19.2 packages/browser-playwright: dependencies: From f5c9a12cdf9be0fd6853616ed2b7f8b3aade97f0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 15:37:04 +0200 Subject: [PATCH 12/43] chore: cleanup --- packages/vitest/rollup.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index f4d3a7921762..d5288bee78e3 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -70,6 +70,7 @@ const external = [ 'node:console', 'inspector', 'vitest/optional-types.js', + 'vitest/browser', 'vite/module-runner', '@vitest/mocker', '@vitest/mocker/node', From 37b7d3c5a35611fc45e5f4848c6a15cea838f229 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 15:48:55 +0200 Subject: [PATCH 13/43] chore: rename "@vitest/browser/context" to "vitest/browser" --- docs/guide/browser/assertion-api.md | 6 +- docs/guide/browser/commands.md | 12 +- docs/guide/browser/component-testing.md | 4 +- docs/guide/browser/context.md | 2 +- docs/guide/browser/index.md | 10 +- docs/guide/browser/interactivity-api.md | 36 +- docs/guide/browser/locators.md | 26 +- docs/guide/browser/playwright.md | 2 +- .../browser/visual-regression-testing.md | 2 +- examples/lit/test/basic.test.ts | 2 +- .../browser-playwright/src/commands/trace.ts | 16 +- .../browser-playwright/src/commands/upload.ts | 2 +- .../browser-playwright/src/commands/utils.ts | 2 +- packages/browser-playwright/src/playwright.ts | 8 +- .../src/commands/upload.ts | 2 +- .../browser-webdriverio/src/webdriverio.ts | 4 +- packages/browser/aria-role.d.ts | 96 --- packages/browser/context.d.ts | 768 ------------------ packages/browser/context.js | 19 - packages/browser/jest-dom.d.ts | 724 ----------------- packages/browser/matchers.d.ts | 29 - .../src/client/tester/expect-element.ts | 2 +- .../src/client/tester/expect/toBeVisible.ts | 2 +- .../src/client/tester/expect/toHaveStyle.ts | 2 +- .../src/client/tester/locators/index.ts | 6 +- .../browser/src/client/tester/public-utils.ts | 4 +- packages/browser/src/client/tester/runner.ts | 2 +- .../browser/src/client/tester/tester-utils.ts | 2 +- packages/browser/src/client/tester/tester.ts | 2 +- packages/browser/src/client/vite.config.ts | 2 +- packages/browser/src/node/commands/utils.ts | 2 +- .../browser/src/node/plugins/pluginContext.ts | 4 +- packages/browser/utils.d.ts | 2 +- packages/coverage-v8/src/browser.ts | 2 +- packages/vitest/browser/context.d.ts | 2 +- .../vitest/src/node/config/resolveConfig.ts | 8 +- packages/vitest/src/node/types/browser.ts | 4 +- packages/vitest/src/utils/graph.ts | 2 +- .../broken-iframe/submit-form.test.ts | 2 +- .../browser-crash/browser-crash.test.ts | 4 +- .../fixtures/expect-dom/toHaveLength.test.ts | 2 +- .../expect-dom/toMatchScreenshot.test.ts | 2 +- test/browser/fixtures/failing/failing.test.ts | 2 +- .../fixtures/locators-custom/basic.test.tsx | 4 +- test/browser/fixtures/locators/blog.test.tsx | 2 +- test/browser/fixtures/locators/query.test.ts | 2 +- .../multiple-different-configs/basic.test.js | 2 +- .../timeout-hooks/hooks-timeout.test.ts | 2 +- test/browser/fixtures/timeout/timeout.test.ts | 2 +- .../fixtures/user-event/cleanup-retry.test.ts | 2 +- .../fixtures/user-event/cleanup1.test.ts | 2 +- .../fixtures/user-event/cleanup2.test.ts | 2 +- .../fixtures/user-event/clipboard.test.ts | 2 +- .../fixtures/user-event/keyboard.test.ts | 2 +- test/browser/fixtures/viewport/basic.test.ts | 2 +- .../browser/specs/to-match-screenshot.test.ts | 2 +- test/browser/test/cdp.test.ts | 2 +- test/browser/test/commands.test.ts | 4 +- test/browser/test/dom.test.ts | 2 +- test/browser/test/expect-element.test.ts | 2 +- test/browser/test/iframe.test.ts | 2 +- test/browser/test/userEvent.test.ts | 2 +- test/browser/test/utils.test.ts | 2 +- test/browser/test/viewport.test.ts | 2 +- .../fails/node-browser-context.test.ts | 2 +- .../cli/test/__snapshots__/fails.test.ts.snap | 2 +- test/config/test/browser-configs.test.ts | 12 + test/dts-playwright/src/basic.test.ts | 2 +- 68 files changed, 139 insertions(+), 1759 deletions(-) delete mode 100644 packages/browser/aria-role.d.ts delete mode 100644 packages/browser/context.d.ts delete mode 100644 packages/browser/context.js delete mode 100644 packages/browser/jest-dom.d.ts delete mode 100644 packages/browser/matchers.d.ts diff --git a/docs/guide/browser/assertion-api.md b/docs/guide/browser/assertion-api.md index b10901a5b1c9..4c8e4b44a2d0 100644 --- a/docs/guide/browser/assertion-api.md +++ b/docs/guide/browser/assertion-api.md @@ -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 -/// +/// ``` ::: @@ -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() diff --git a/docs/guide/browser/commands.md b/docs/guide/browser/commands.md index 0b7bc1254905..8e37044306b9 100644 --- a/docs/guide/browser/commands.md +++ b/docs/guide/browser/commands.md @@ -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 diff --git a/docs/guide/browser/component-testing.md b/docs/guide/browser/component-testing.md index 1f7d7a389c79..fe6e80f4d4e6 100644 --- a/docs/guide/browser/component-testing.md +++ b/docs/guide/browser/component-testing.md @@ -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 diff --git a/docs/guide/browser/context.md b/docs/guide/browser/context.md index d8710a0cdfc1..8bedda3b1178 100644 --- a/docs/guide/browser/context.md +++ b/docs/guide/browser/context.md @@ -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` diff --git a/docs/guide/browser/index.md b/docs/guide/browser/index.md index adb543221465..aceebb09cca7 100644 --- a/docs/guide/browser/index.md +++ b/docs/guide/browser/index.md @@ -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 diff --git a/docs/guide/browser/interactivity-api.md b/docs/guide/browser/interactivity-api.md index b9a6c2de7033..301ade07c19f 100644 --- a/docs/guide/browser/interactivity-api.md +++ b/docs/guide/browser/interactivity-api.md @@ -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 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 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 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' diff --git a/docs/guide/browser/locators.md b/docs/guide/browser/locators.md index e442e9dc2259..f3d93f3437d1 100644 --- a/docs/guide/browser/locators.md +++ b/docs/guide/browser/locators.md @@ -609,7 +609,7 @@ function click(options?: UserEventClickOptions): Promise 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 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 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 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 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 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 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 ``) be included or not. By default, the filter is not applied. - * - * See [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked) for more information - */ - checked?: boolean - /** - * Should disabled elements be included or not. By default, the filter is not applied. Note that unlike other attributes, `disable` state is inherited. - * - * See [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled) for more information - */ - disabled?: boolean - /** - * Should expanded elements be included or not. By default, the filter is not applied. - * - * See [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded) for more information - */ - expanded?: boolean - /** - * Should elements that are [normally excluded](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion) from the accessibility tree be queried. By default, only non-hidden elements are matched by role selector. - * - * Note that roles `none` and `presentation` are always included. - * @default false - */ - includeHidden?: boolean - /** - * A number attribute that is usually present for `heading`, `listitem`, `row`, `treeitem` roles with default values for `

-

` elements. By default, the filter is not applied. - * - * See [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level) for more information - */ - level?: number - /** - * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is - * case-insensitive and searches for a substring, use `exact` to control this behavior. - */ - name?: string | RegExp - /** - * Should pressed elements be included or not. By default, the filter is not applied. - * - * See [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed) for more information - */ - pressed?: boolean - /** - * Should selected elements be included or not. By default, the filter is not applied. - * - * See [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected) for more information - */ - selected?: boolean -} - -interface LocatorScreenshotOptions extends Omit {} - -interface LocatorSelectors { - /** - * Creates a way to locate an element by its [ARIA role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles), [ARIA attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes) and [accessible name](https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name). - * @see {@link https://vitest.dev/guide/browser/locators#getbyrole} - */ - getByRole: (role: ARIARole | ({} & string), options?: LocatorByRoleOptions) => Locator - /** - * @see {@link https://vitest.dev/guide/browser/locators#getbylabeltext} - */ - getByLabelText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element with an `alt` attribute that matches the text. Unlike testing-library's implementation, Vitest will match any element that has an `alt` attribute. - * @see {@link https://vitest.dev/guide/browser/locators#getbyalttext} - */ - getByAltText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that has the specified placeholder text. Vitest will match any element that has a matching `placeholder` attribute, not just `input`. - * @see {@link https://vitest.dev/guide/browser/locators#getbyplaceholder} - */ - getByPlaceholder: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that contains the specified text. The text will be matched against TextNode's [`nodeValue`](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue) or input's value if the type is `button` or `reset`. - * Matching by text always normalizes whitespace, even with exact match. - * For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace. - * @see {@link https://vitest.dev/guide/browser/locators#getbytext} - */ - getByText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that has the specified `title` attribute. Unlike testing-library's `getByTitle`, Vitest cannot find `title` elements within an SVG. - * @see {@link https://vitest.dev/guide/browser/locators#getbytitle} - */ - getByTitle: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/config/#browser-locators-testidattribute). - * @see {@link https://vitest.dev/guide/browser/locators#getbytestid} - */ - getByTestId: (text: string | RegExp) => Locator -} - -export interface FrameLocator extends LocatorSelectors {} - -export interface Locator extends LocatorSelectors { - /** - * Selector string that will be used to locate the element by the browser provider. - * You can use this string in the commands API: - * ```ts - * // playwright - * function test({ selector, iframe }) { - * await iframe.locator(selector).click() - * } - * // webdriverio - * function test({ selector, browser }) { - * await browser.$(selector).click() - * } - * ``` - * @see {@link https://vitest.dev/guide/browser/locators#selector} - */ - readonly selector: string - - /** - * The number of elements that this locator is matching. - * @see {@link https://vitest.dev/guide/browser/locators#length} - */ - readonly length: number - - /** - * Click on an element. You can use the options to set the cursor position. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-click} - */ - click(options?: UserEventClickOptions): Promise - /** - * Triggers a double click event on an element. You can use the options to set the cursor position. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dblclick} - */ - dblClick(options?: UserEventDoubleClickOptions): Promise - /** - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-tripleclick} - */ - tripleClick(options?: UserEventTripleClickOptions): Promise - /** - * Clears the input element content - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear} - */ - clear(options?: UserEventClearOptions): Promise - /** - * Moves the cursor position to the selected element - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover} - */ - hover(options?: UserEventHoverOptions): Promise - /** - * This works the same as `locator.hover`, but moves the cursor to the `document.body` element instead. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-unhover} - */ - unhover(options?: UserEventHoverOptions): Promise - /** - * Sets the value of the current `input`, `textarea` or `contenteditable` element. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-fill} - */ - fill(text: string, options?: UserEventFillOptions): Promise - /** - * Drags the current element to the target location. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dropto} - */ - dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise - /** - * Choose one or more values from a ` - * - *
- * - *
- * - * await expect(page.getByTestId('no-aria-invalid')).not.toBeInvalid() - * await expect(page.getByTestId('invalid-form')).toBeInvalid() - * @see https://vitest.dev/guide/browser/assertion-api#tobeinvalid - */ - toBeInvalid(): R - /** - * @description - * This allows you to check if a form element is currently required. - * - * An element is required if it is having a `required` or `aria-required="true"` attribute. - * @example - * - *
- * - * await expect.element(page.getByTestId('required-input')).toBeRequired() - * await expect.element(page.getByTestId('supported-role')).not.toBeRequired() - * @see https://vitest.dev/guide/browser/assertion-api#toberequired - */ - toBeRequired(): R - /** - * @description - * Allows you to check if a form element is currently required. - * - * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no - * value or a value of "false", or if the result of `checkValidity()` is true. - * @example - * - * - *
- * - *
- * - * await expect.element(page.getByTestId('no-aria-invalid')).not.toBeValid() - * await expect.element(page.getByTestId('invalid-form')).toBeInvalid() - * @see https://vitest.dev/guide/browser/assertion-api#tobevalid - */ - toBeValid(): R - /** - * @description - * Allows you to assert whether an element contains another element as a descendant or not. - * @example - * - * - * - * - * const ancestor = page.getByTestId('ancestor') - * const descendant = page.getByTestId('descendant') - * const nonExistentElement = page.getByTestId('does-not-exist') - * await expect.element(ancestor).toContainElement(descendant) - * await expect.element(descendant).not.toContainElement(ancestor) - * await expect.element(ancestor).not.toContainElement(nonExistentElement) - * @see https://vitest.dev/guide/browser/assertion-api#tocontainelement - */ - toContainElement(element: HTMLElement | SVGElement | null): R - /** - * @description - * Assert whether a string representing a HTML element is contained in another element. - * @example - * - * - * const parent = page.getByTestId('parent') - * await expect.element(parent).toContainHTML('') - * @see https://vitest.dev/guide/browser/assertion-api#tocontainhtml - */ - toContainHTML(htmlText: string): R - /** - * @description - * Allows you to check if a given element has an attribute or not. - * - * You can also optionally check that the attribute has a specific expected value or partial match using - * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or - * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). - * @example - * - * - * await expect.element(button).toHaveAttribute('disabled') - * await expect.element(button).toHaveAttribute('type', 'submit') - * await expect.element(button).not.toHaveAttribute('type', 'button') - * @see https://vitest.dev/guide/browser/assertion-api#tohaveattribute - */ - toHaveAttribute(attr: string, value?: unknown): R - /** - * @description - * Check whether the given element has certain classes within its `class` attribute. - * - * You must provide at least one class, unless you are asserting that an element does not have any classes. - * @example - * - * - *
no classes
- * - * const deleteButton = page.getByTestId('delete-button') - * const noClasses = page.getByTestId('no-classes') - * await expect.element(deleteButton).toHaveClass('btn') - * await expect.element(deleteButton).toHaveClass('btn-danger xs') - * await expect.element(deleteButton).toHaveClass(/danger/, 'xs') - * await expect.element(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) - * await expect.element(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) - * await expect.element(noClasses).not.toHaveClass() - * @see https://vitest.dev/guide/browser/assertion-api#tohaveclass - */ - toHaveClass(...classNames: - | (string | RegExp)[] - | [string, options?: {exact: boolean}] - | [string, string, options?: {exact: boolean}] - | [string, string, string, options?: {exact: boolean}] - | [string, string, string, string, options?: {exact: boolean}] - | [string, string, string, string, string, options?: {exact: boolean}] - | [string, string, string, string, string, string, options?: {exact: boolean}] - | [string, string, string, string, string, string, string, options?: {exact: boolean}] - | [string, string, string, string, string, string, string, string, options?: {exact: boolean}] - | [string, string, string, string, string, string, string, string, string, options?: {exact: boolean}] - ): R - /** - * @description - * This allows you to check whether the given form element has the specified displayed value (the one the - * end user will see). It accepts , - * - * - * - * - * - * - * - * const input = page.getByLabelText('First name') - * const textarea = page.getByLabelText('Description') - * const selectSingle = page.getByLabelText('Fruit') - * const selectMultiple = page.getByLabelText('Fruits') - * - * await expect.element(input).toHaveDisplayValue('Luca') - * await expect.element(textarea).toHaveDisplayValue('An example description here.') - * await expect.element(selectSingle).toHaveDisplayValue('Select a fruit...') - * await expect.element(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) - * - * @see https://vitest.dev/guide/browser/assertion-api#tohavedisplayvalue - */ - toHaveDisplayValue(value: string | number | RegExp | Array): R - /** - * @description - * Assert whether an element has focus or not. - * @example - *
- * - *
- * - * const input = page.getByTestId('element-to-focus') - * input.element().focus() - * await expect.element(input).toHaveFocus() - * input.element().blur() - * await expect.element(input).not.toHaveFocus() - * @see https://vitest.dev/guide/browser/assertion-api#tohavefocus - */ - toHaveFocus(): R - /** - * @description - * Check if a form or fieldset contains form controls for each given name, and having the specified value. - * - * Can only be invoked on a form or fieldset element. - * @example - *
- * - * - * - * - *
- * - * await expect.element(page.getByTestId('login-form')).toHaveFormValues({ - * username: 'jane.doe', - * rememberMe: true, - * }) - * @see https://vitest.dev/guide/browser/assertion-api#tohaveformvalues - */ - toHaveFormValues(expectedValues: Record): R - /** - * @description - * Check if an element has specific css properties with specific values applied. - * - * Only matches if the element has *all* the expected properties applied, not just some of them. - * @example - * - * - * const button = page.getByTestId('submit-button') - * await expect.element(button).toHaveStyle('background-color: green') - * await expect.element(button).toHaveStyle({ - * 'background-color': 'green', - * display: 'none' - * }) - * @see https://vitest.dev/guide/browser/assertion-api#tohavestyle - */ - toHaveStyle(css: string | Partial): R - /** - * @description - * Check whether the given element has a text content or not. - * - * When a string argument is passed through, it will perform a partial case-sensitive match to the element - * content. - * - * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. - * - * If you want to match the whole content, you can use a RegExp to do it. - * @example - * Text Content - * - * const element = page.getByTestId('text-content') - * await expect.element(element).toHaveTextContent('Content') - * // to match the whole content - * await expect.element(element).toHaveTextContent(/^Text Content$/) - * // to use case-insensitive match - * await expect.element(element).toHaveTextContent(/content$/i) - * await expect.element(element).not.toHaveTextContent('content') - * @see https://vitest.dev/guide/browser/assertion-api#tohavetextcontent - */ - toHaveTextContent( - text: string | number | RegExp, - options?: {normalizeWhitespace: boolean}, - ): R - /** - * @description - * Check whether the given form element has the specified value. - * - * Accepts ``, ` - *

prev

- *

text selected text

- *

next

- *
- * - * page.getByTestId('text').element().setSelectionRange(5, 13) - * await expect.element(page.getByTestId('text')).toHaveSelection('selected') - * - * page.getByTestId('textarea').element().setSelectionRange(0, 5) - * await expect.element('textarea').toHaveSelection('text ') - * - * const selection = document.getSelection() - * const range = document.createRange() - * selection.removeAllRanges() - * selection.empty() - * selection.addRange(range) - * - * // selection of child applies to the parent as well - * range.selectNodeContents(page.getByTestId('child').element()) - * await expect.element(page.getByTestId('child')).toHaveSelection('selected') - * await expect.element(page.getByTestId('parent')).toHaveSelection('selected') - * - * // selection that applies from prev all, parent text before child, and part child. - * range.setStart(page.getByTestId('prev').element(), 0) - * range.setEnd(page.getByTestId('child').element().childNodes[0], 3) - * await expect.element(page.queryByTestId('prev')).toHaveSelection('prev') - * await expect.element(page.queryByTestId('child')).toHaveSelection('sel') - * await expect.element(page.queryByTestId('parent')).toHaveSelection('text sel') - * await expect.element(page.queryByTestId('next')).not.toHaveSelection() - * - * // selection that applies from part child, parent text after child and part next. - * range.setStart(page.getByTestId('child').element().childNodes[0], 3) - * range.setEnd(page.getByTestId('next').element().childNodes[0], 2) - * await expect.element(page.queryByTestId('child')).toHaveSelection('ected') - * await expect.element(page.queryByTestId('parent')).toHaveSelection('ected text') - * await expect.element(page.queryByTestId('prev')).not.toHaveSelection() - * await expect.element(page.queryByTestId('next')).toHaveSelection('ne') - * - * @see https://vitest.dev/guide/browser/assertion-api#tohaveselection - */ - toHaveSelection(selection?: string): R - - /** - * @description - * This assertion allows you to perform visual regression testing by comparing - * screenshots of elements or pages against stored reference images. - * - * When differences are detected beyond the configured threshold, the test fails. - * To help identify the changes, the assertion generates: - * - * - The actual screenshot captured during the test - * - The expected reference screenshot - * - A diff image highlighting the differences (when possible) - * - * @example - * - * - * // basic usage, auto-generates screenshot name - * await expect.element(getByTestId('button')).toMatchScreenshot() - * - * // with custom name - * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button') - * - * // with options - * await expect.element(getByTestId('button')).toMatchScreenshot({ - * comparatorName: 'pixelmatch', - * comparatorOptions: { - * allowedMismatchedPixelRatio: 0.01, - * }, - * }) - * - * // with both name and options - * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button', { - * comparatorName: 'pixelmatch', - * comparatorOptions: { - * allowedMismatchedPixelRatio: 0.01, - * }, - * }) - * - * @see https://vitest.dev/guide/browser/assertion-api#tomatchscreenshot - */ - toMatchScreenshot( - options?: ScreenshotMatcherOptions, - ): Promise - toMatchScreenshot( - name?: string, - options?: ScreenshotMatcherOptions, - ): Promise -} diff --git a/packages/browser/matchers.d.ts b/packages/browser/matchers.d.ts deleted file mode 100644 index 0d05313b9528..000000000000 --- a/packages/browser/matchers.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Locator } from '@vitest/browser/context' -import type { TestingLibraryMatchers } from './jest-dom.js' -import type { Assertion, ExpectPollOptions } from 'vitest' - -declare module 'vitest' { - interface JestAssertion extends TestingLibraryMatchers {} - interface AsymmetricMatchersContaining extends TestingLibraryMatchers {} - - type Promisify = { - [K in keyof O]: O[K] extends (...args: infer A) => infer R - ? O extends R - ? Promisify - : (...args: A) => Promise - : O[K]; - } - - type PromisifyDomAssertion = Promisify> - - interface ExpectStatic { - /** - * `expect.element(locator)` is a shorthand for `expect.poll(() => locator.element())`. - * You can set default timeout via `expect.poll.timeout` option in the config. - * @see {@link https://vitest.dev/api/expect#poll} - */ - element: (element: T, options?: ExpectPollOptions) => PromisifyDomAssertion> - } -} - -export {} diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index e86ae304cc66..c7854cafc070 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -1,5 +1,5 @@ -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' diff --git a/packages/browser/src/client/tester/expect/toBeVisible.ts b/packages/browser/src/client/tester/expect/toBeVisible.ts index afa599b629bc..808951ad4c0e 100644 --- a/packages/browser/src/client/tester/expect/toBeVisible.ts +++ b/packages/browser/src/client/tester/expect/toBeVisible.ts @@ -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( diff --git a/packages/browser/src/client/tester/expect/toHaveStyle.ts b/packages/browser/src/client/tester/expect/toHaveStyle.ts index df7e28ade92c..03b7f8d2e93b 100644 --- a/packages/browser/src/client/tester/expect/toHaveStyle.ts +++ b/packages/browser/src/client/tester/expect/toHaveStyle.ts @@ -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 diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index ecdba9ae3635..b83a95f70ed1 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -1,3 +1,4 @@ +import type { ParsedSelector } from 'ivya' import type { LocatorByRoleOptions, LocatorOptions, @@ -9,9 +10,7 @@ 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 { getByAltTextSelector, getByLabelSelector, @@ -22,6 +21,7 @@ import { getByTitleSelector, Ivya, } from 'ivya' +import { page, server } from 'vitest/browser' import { ensureAwaited, getBrowserState } from '../../utils' import { getElementError } from '../public-utils' import { escapeForTextSelector, isLocator } from '../tester-utils' diff --git a/packages/browser/src/client/tester/public-utils.ts b/packages/browser/src/client/tester/public-utils.ts index ea005e319387..2b64122fa0ec 100644 --- a/packages/browser/src/client/tester/public-utils.ts +++ b/packages/browser/src/client/tester/public-utils.ts @@ -1,7 +1,7 @@ -import type { Locator, LocatorSelectors } from '@vitest/browser/context' +import type { Locator, LocatorSelectors } from 'vitest/browser' import type { StringifyOptions } from 'vitest/internal/browser' -import { locators, page } from '@vitest/browser/context' import { asLocator } from 'ivya' +import { locators, page } from 'vitest/browser' import { stringify } from 'vitest/internal/browser' export function getElementLocatorSelectors(element: Element): LocatorSelectors { diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 9e36352b070d..d02eea215409 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -13,8 +13,8 @@ import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'v import type { VitestBrowserClientMocker } from './mocker' 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, diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 6233c96efa7c..c22fef6c4c7c 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -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' diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 1fa6aa16a14a..34cdf4e8b5f2 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -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, diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts index a4814a6a43be..95c64a6901ce 100644 --- a/packages/browser/src/client/vite.config.ts +++ b/packages/browser/src/client/vite.config.ts @@ -35,7 +35,7 @@ export default vite.defineConfig({ /^vitest\//, 'vitest', /^msw/, - '@vitest/browser/context', + 'vitest/browser', '@vitest/browser/client', ], }, diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 454171f68b1d..f1cfc6c6d08f 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -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 any> = BrowserCommand< diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 51a98eb80e6a..43d5c8078f70 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url' import { slash } from '@vitest/utils/helpers' import { dirname, resolve } from 'pathe' -const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context' -const ID_CONTEXT = '@vitest/browser/context' +const VIRTUAL_ID_CONTEXT = '\0vitest/browser' +const ID_CONTEXT = 'vitest/browser' const __dirname = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/browser/utils.d.ts b/packages/browser/utils.d.ts index 72d08cce6e4f..7dcbb52886cc 100644 --- a/packages/browser/utils.d.ts +++ b/packages/browser/utils.d.ts @@ -2,7 +2,7 @@ // we cannot bundle it because vitest depend on the @vitest/browser and vice versa // fortunately, the file is quite small -import { LocatorSelectors, Locator } from '@vitest/browser/context' +import { LocatorSelectors, Locator } from 'vitest/browser' import { StringifyOptions } from 'vitest/internal/browser' export type PrettyDOMOptions = Omit diff --git a/packages/coverage-v8/src/browser.ts b/packages/coverage-v8/src/browser.ts index e1a668df7b2d..7b93a345fd9e 100644 --- a/packages/coverage-v8/src/browser.ts +++ b/packages/coverage-v8/src/browser.ts @@ -1,6 +1,6 @@ import type { CoverageProviderModule } from 'vitest/node' import type { V8CoverageProvider } from './provider' -import { cdp } from '@vitest/browser/context' +import { cdp } from 'vitest/browser' import { loadProvider } from './load-provider' const session = cdp() diff --git a/packages/vitest/browser/context.d.ts b/packages/vitest/browser/context.d.ts index d428ee49cfeb..cb23b7ed3de7 100644 --- a/packages/vitest/browser/context.d.ts +++ b/packages/vitest/browser/context.d.ts @@ -186,7 +186,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 diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 1206363046de..18ed105b579d 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -750,10 +750,6 @@ export function resolveConfig( resolved.browser.locators ??= {} as any resolved.browser.locators.testIdAttribute ??= 'data-testid' - if (resolved.browser.enabled && stdProvider === 'stackblitz') { - resolved.browser.provider = undefined // reset to "preview" - } - if (typeof resolved.browser.provider === 'string') { const source = `@vitest/browser-${resolved.browser.provider}` throw new TypeError( @@ -763,6 +759,10 @@ export function resolveConfig( } const isPreview = resolved.browser.provider?.name === 'preview' + + if (!isPreview && resolved.browser.enabled && stdProvider === 'stackblitz') { + throw new Error(`stackblitz environment does not support the ${resolved.browser.provider?.name} provider. Please, use "@vitest/browser-preview" instead.`) + } if (isPreview && resolved.browser.screenshotFailures === true) { console.warn(c.yellow( [ diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 3a314e9a669b..15e9ebef721a 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -130,7 +130,7 @@ export interface BrowserConfigOptions { * }) * ``` */ - provider: BrowserProviderOption + provider?: BrowserProviderOption /** * enable headless mode @@ -247,7 +247,7 @@ export interface BrowserConfigOptions { /** * Commands that will be executed on the server - * via the browser `import("@vitest/browser/context").commands` API. + * via the browser `import("vitest/browser").commands` API. * @see {@link https://vitest.dev/guide/browser/commands} */ commands?: Record> diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/graph.ts index 875307dc6da6..d3afaa759d62 100644 --- a/packages/vitest/src/utils/graph.ts +++ b/packages/vitest/src/utils/graph.ts @@ -18,7 +18,7 @@ export async function getModuleGraph( if (!mod || !mod.id) { return } - if (mod.id === '\0@vitest/browser/context') { + if (mod.id === '\0vitest/browser') { return } if (seen.has(mod)) { diff --git a/test/browser/fixtures/broken-iframe/submit-form.test.ts b/test/browser/fixtures/broken-iframe/submit-form.test.ts index 2b8f245d3d90..a3d76a9e264d 100644 --- a/test/browser/fixtures/broken-iframe/submit-form.test.ts +++ b/test/browser/fixtures/broken-iframe/submit-form.test.ts @@ -1,4 +1,4 @@ -import { userEvent } from '@vitest/browser/context'; +import { userEvent } from 'vitest/browser'; import { test } from 'vitest'; test('submitting a form reloads the iframe with "?" query', async () => { diff --git a/test/browser/fixtures/browser-crash/browser-crash.test.ts b/test/browser/fixtures/browser-crash/browser-crash.test.ts index eccc9f375616..7bff8d59a1ae 100644 --- a/test/browser/fixtures/browser-crash/browser-crash.test.ts +++ b/test/browser/fixtures/browser-crash/browser-crash.test.ts @@ -1,7 +1,7 @@ -import { commands } from '@vitest/browser/context' +import { commands } from 'vitest/browser' import { it } from 'vitest' -declare module '@vitest/browser/context' { +declare module 'vitest/browser' { interface BrowserCommands { forceCrash: () => Promise } diff --git a/test/browser/fixtures/expect-dom/toHaveLength.test.ts b/test/browser/fixtures/expect-dom/toHaveLength.test.ts index 17455f5abf41..8734af7268a5 100644 --- a/test/browser/fixtures/expect-dom/toHaveLength.test.ts +++ b/test/browser/fixtures/expect-dom/toHaveLength.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { render } from './utils'; -import { page } from '@vitest/browser/context'; +import { page } from 'vitest/browser'; describe('.toHaveLength', () => { test('accepts locator', async () => { diff --git a/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts b/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts index 8c1c928320d7..7ac7ced6ae73 100644 --- a/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts +++ b/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'vitest' import { extractToMatchScreenshotPaths, render } from './utils' -import { page, server } from '@vitest/browser/context' +import { page, server } from 'vitest/browser' import { join } from 'pathe' const blockSize = 19 diff --git a/test/browser/fixtures/failing/failing.test.ts b/test/browser/fixtures/failing/failing.test.ts index 7dc7689a84c2..9f3e18d75455 100644 --- a/test/browser/fixtures/failing/failing.test.ts +++ b/test/browser/fixtures/failing/failing.test.ts @@ -1,4 +1,4 @@ -import { page } from '@vitest/browser/context' +import { page } from 'vitest/browser' import { index } from '@vitest/bundled-lib' import { expect, it } from 'vitest' import { throwError } from './src/error' diff --git a/test/browser/fixtures/locators-custom/basic.test.tsx b/test/browser/fixtures/locators-custom/basic.test.tsx index ac2dabf8343c..d39ec6b92fe6 100644 --- a/test/browser/fixtures/locators-custom/basic.test.tsx +++ b/test/browser/fixtures/locators-custom/basic.test.tsx @@ -1,8 +1,8 @@ -import { type Locator, locators, page } from '@vitest/browser/context'; +import { type Locator, locators, page } from 'vitest/browser'; import { beforeEach, expect, test } from 'vitest'; import { getElementLocatorSelectors } from '@vitest/browser/utils' -declare module '@vitest/browser/context' { +declare module 'vitest/browser' { interface LocatorSelectors { getByCustomTitle: (title: string) => Locator getByNestedTitle: (title: string) => Locator diff --git a/test/browser/fixtures/locators/blog.test.tsx b/test/browser/fixtures/locators/blog.test.tsx index 65faef58c9b7..5502c3a9fa48 100644 --- a/test/browser/fixtures/locators/blog.test.tsx +++ b/test/browser/fixtures/locators/blog.test.tsx @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { page, userEvent } from '@vitest/browser/context' +import { page, userEvent } from 'vitest/browser' import Blog from '../../src/blog-app/blog' test('renders blog posts', async () => { diff --git a/test/browser/fixtures/locators/query.test.ts b/test/browser/fixtures/locators/query.test.ts index 68a8d5f96a97..2c5e288bfd99 100644 --- a/test/browser/fixtures/locators/query.test.ts +++ b/test/browser/fixtures/locators/query.test.ts @@ -1,4 +1,4 @@ -import { page } from '@vitest/browser/context'; +import { page } from 'vitest/browser'; import { afterEach, describe, expect, test } from 'vitest'; afterEach(() => { diff --git a/test/browser/fixtures/multiple-different-configs/basic.test.js b/test/browser/fixtures/multiple-different-configs/basic.test.js index f5480f9199a5..a9d31f9585bd 100644 --- a/test/browser/fixtures/multiple-different-configs/basic.test.js +++ b/test/browser/fixtures/multiple-different-configs/basic.test.js @@ -1,5 +1,5 @@ import { test as baseTest, expect, inject } from 'vitest'; -import { server } from '@vitest/browser/context' +import { server } from 'vitest/browser' const test = baseTest.extend({ // chromium should inject the value as "true" diff --git a/test/browser/fixtures/timeout-hooks/hooks-timeout.test.ts b/test/browser/fixtures/timeout-hooks/hooks-timeout.test.ts index c7c51c55ba13..430a831ccd6f 100644 --- a/test/browser/fixtures/timeout-hooks/hooks-timeout.test.ts +++ b/test/browser/fixtures/timeout-hooks/hooks-timeout.test.ts @@ -1,4 +1,4 @@ -import { page, server } from '@vitest/browser/context'; +import { page, server } from 'vitest/browser'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'; describe.runIf(server.provider === 'playwright')('timeouts are failing correctly', () => { diff --git a/test/browser/fixtures/timeout/timeout.test.ts b/test/browser/fixtures/timeout/timeout.test.ts index 2ea9982805f6..ec55d9bfb36b 100644 --- a/test/browser/fixtures/timeout/timeout.test.ts +++ b/test/browser/fixtures/timeout/timeout.test.ts @@ -1,4 +1,4 @@ -import { page } from '@vitest/browser/context'; +import { page } from 'vitest/browser'; import { afterEach, expect, test } from 'vitest'; afterEach(() => { diff --git a/test/browser/fixtures/user-event/cleanup-retry.test.ts b/test/browser/fixtures/user-event/cleanup-retry.test.ts index a2e68d579e72..a0938152f065 100644 --- a/test/browser/fixtures/user-event/cleanup-retry.test.ts +++ b/test/browser/fixtures/user-event/cleanup-retry.test.ts @@ -1,5 +1,5 @@ import { expect, onTestFinished, test } from 'vitest' -import { userEvent } from '@vitest/browser/context' +import { userEvent } from 'vitest/browser' test('cleanup retry', { retry: 1 }, async (ctx) => { let logs: any[] = []; diff --git a/test/browser/fixtures/user-event/cleanup1.test.ts b/test/browser/fixtures/user-event/cleanup1.test.ts index 9b6ef3996ed6..ee88a9bab205 100644 --- a/test/browser/fixtures/user-event/cleanup1.test.ts +++ b/test/browser/fixtures/user-event/cleanup1.test.ts @@ -1,5 +1,5 @@ import { expect, onTestFinished, test } from 'vitest' -import { userEvent } from '@vitest/browser/context' +import { userEvent } from 'vitest/browser' test('cleanup1', async () => { let logs: any[] = []; diff --git a/test/browser/fixtures/user-event/cleanup2.test.ts b/test/browser/fixtures/user-event/cleanup2.test.ts index 75e66d124050..abf69feae310 100644 --- a/test/browser/fixtures/user-event/cleanup2.test.ts +++ b/test/browser/fixtures/user-event/cleanup2.test.ts @@ -1,5 +1,5 @@ import { expect, onTestFinished, test } from 'vitest' -import { userEvent } from '@vitest/browser/context' +import { userEvent } from 'vitest/browser' // test per-test-file cleanup just in case diff --git a/test/browser/fixtures/user-event/clipboard.test.ts b/test/browser/fixtures/user-event/clipboard.test.ts index 18f286a7ed29..0b65562e422e 100644 --- a/test/browser/fixtures/user-event/clipboard.test.ts +++ b/test/browser/fixtures/user-event/clipboard.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { page, userEvent } from '@vitest/browser/context'; +import { page, userEvent } from 'vitest/browser'; test('clipboard', async () => { // make it smaller since webdriverio fails when scaled diff --git a/test/browser/fixtures/user-event/keyboard.test.ts b/test/browser/fixtures/user-event/keyboard.test.ts index 8e0f3cadcdcf..aced4c255e5c 100644 --- a/test/browser/fixtures/user-event/keyboard.test.ts +++ b/test/browser/fixtures/user-event/keyboard.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { userEvent, page, server } from '@vitest/browser/context' +import { userEvent, page, server } from 'vitest/browser' test('non US keys', async () => { document.body.innerHTML = ` diff --git a/test/browser/fixtures/viewport/basic.test.ts b/test/browser/fixtures/viewport/basic.test.ts index 56746956d933..6791cdfb526e 100644 --- a/test/browser/fixtures/viewport/basic.test.ts +++ b/test/browser/fixtures/viewport/basic.test.ts @@ -1,4 +1,4 @@ -import { page, userEvent, server } from "@vitest/browser/context"; +import { page, userEvent, server } from "vitest/browser"; import { expect, test } from "vitest"; test("drag and drop over large viewport", async () => { diff --git a/test/browser/specs/to-match-screenshot.test.ts b/test/browser/specs/to-match-screenshot.test.ts index b410991cdc0a..a8a61fb01ba1 100644 --- a/test/browser/specs/to-match-screenshot.test.ts +++ b/test/browser/specs/to-match-screenshot.test.ts @@ -13,7 +13,7 @@ const testName = 'screenshot-snapshot' const bgColor = '#fff' const testContent = /* ts */` -import { page, server } from '@vitest/browser/context' +import { page, server } from 'vitest/browser' import { describe, test } from 'vitest' import { render } from './utils' diff --git a/test/browser/test/cdp.test.ts b/test/browser/test/cdp.test.ts index fce095e4165a..43c934a6e4e9 100644 --- a/test/browser/test/cdp.test.ts +++ b/test/browser/test/cdp.test.ts @@ -1,5 +1,5 @@ -import { cdp, server } from '@vitest/browser/context' import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { cdp, server } from 'vitest/browser' describe.runIf( server.provider === 'playwright' && server.browser === 'chromium', diff --git a/test/browser/test/commands.test.ts b/test/browser/test/commands.test.ts index 0276e0d3095c..24716753633e 100644 --- a/test/browser/test/commands.test.ts +++ b/test/browser/test/commands.test.ts @@ -1,5 +1,5 @@ -import { server } from '@vitest/browser/context' import { expect, it } from 'vitest' +import { server } from 'vitest/browser' const { readFile, writeFile, removeFile, myCustomCommand } = server.commands @@ -51,7 +51,7 @@ it('can run custom commands', async () => { }) }) -declare module '@vitest/browser/context' { +declare module 'vitest/browser' { interface BrowserCommands { myCustomCommand: (arg1: string, arg2: string) => Promise<{ testPath: string diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 1f2828e58c77..a7115b93d057 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -1,6 +1,6 @@ import { createNode } from '#src/createNode' -import { page, server } from '@vitest/browser/context' import { afterAll, beforeEach, describe, expect, test } from 'vitest' +import { page, server } from 'vitest/browser' import '../src/button.css' afterAll(() => { diff --git a/test/browser/test/expect-element.test.ts b/test/browser/test/expect-element.test.ts index 22bcf5532d44..aba9ce83c5f1 100644 --- a/test/browser/test/expect-element.test.ts +++ b/test/browser/test/expect-element.test.ts @@ -1,5 +1,5 @@ -import { page } from '@vitest/browser/context' import { expect, test, vi } from 'vitest' +import { page } from 'vitest/browser' // element selector uses prettyDOM under the hood, which is an expensive call // that should not be called on each failed locator attempt to avoid memory leak: diff --git a/test/browser/test/iframe.test.ts b/test/browser/test/iframe.test.ts index a9916323fdc0..cea9145f6ad0 100644 --- a/test/browser/test/iframe.test.ts +++ b/test/browser/test/iframe.test.ts @@ -1,5 +1,5 @@ -import { page, server } from '@vitest/browser/context' import { expect, test } from 'vitest' +import { page, server } from 'vitest/browser' test.runIf(server.provider === 'playwright')('locates an iframe', async () => { const iframe = document.createElement('iframe') diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts index 370ef9e802d5..838a4c38b353 100644 --- a/test/browser/test/userEvent.test.ts +++ b/test/browser/test/userEvent.test.ts @@ -1,5 +1,5 @@ -import { userEvent as _uE, server } from '@vitest/browser/context' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { userEvent as _uE, server } from 'vitest/browser' import '../src/button.css' beforeEach(() => { diff --git a/test/browser/test/utils.test.ts b/test/browser/test/utils.test.ts index 168e53066308..57f6241c9409 100644 --- a/test/browser/test/utils.test.ts +++ b/test/browser/test/utils.test.ts @@ -1,6 +1,6 @@ -import { commands } from '@vitest/browser/context' import { prettyDOM } from '@vitest/browser/utils' import { afterEach, expect, it, test } from 'vitest' +import { commands } from 'vitest/browser' import { inspect } from 'vitest/internal/browser' diff --git a/test/browser/test/viewport.test.ts b/test/browser/test/viewport.test.ts index b3f564adf78c..5dbf073cf8f9 100644 --- a/test/browser/test/viewport.test.ts +++ b/test/browser/test/viewport.test.ts @@ -1,5 +1,5 @@ -import { server } from '@vitest/browser/context' import { describe, expect, it } from 'vitest' +import { server } from 'vitest/browser' describe.skipIf( // preview cannot control viewport diff --git a/test/cli/fixtures/fails/node-browser-context.test.ts b/test/cli/fixtures/fails/node-browser-context.test.ts index af854dc4012a..3bb99a56fd56 100644 --- a/test/cli/fixtures/fails/node-browser-context.test.ts +++ b/test/cli/fixtures/fails/node-browser-context.test.ts @@ -1,3 +1,3 @@ -import { page } from '@vitest/browser/context' +import { page } from 'vitest/browser' console.log(page) diff --git a/test/cli/test/__snapshots__/fails.test.ts.snap b/test/cli/test/__snapshots__/fails.test.ts.snap index 78cd736b7d35..cd2e1903c851 100644 --- a/test/cli/test/__snapshots__/fails.test.ts.snap +++ b/test/cli/test/__snapshots__/fails.test.ts.snap @@ -61,7 +61,7 @@ exports[`should fail nested-suite.test.ts 1`] = `"AssertionError: expected true exports[`should fail no-assertions.test.ts 1`] = `"Error: expected any number of assertion, but got none"`; -exports[`should fail node-browser-context.test.ts 1`] = `"Error: @vitest/browser/context can be imported only inside the Browser Mode. Your test is running in forks pool. Make sure your regular tests are excluded from the "test.include" glob pattern."`; +exports[`should fail node-browser-context.test.ts 1`] = `"Error: vitest/browser can be imported only inside the Browser Mode. Your test is running in forks pool. Make sure your regular tests are excluded from the "test.include" glob pattern."`; exports[`should fail poll-no-awaited.test.ts 1`] = ` "Error: expect.poll(assertion).toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections: diff --git a/test/config/test/browser-configs.test.ts b/test/config/test/browser-configs.test.ts index 89c9fa45a42f..e800e62edcfa 100644 --- a/test/config/test/browser-configs.test.ts +++ b/test/config/test/browser-configs.test.ts @@ -21,6 +21,7 @@ test('assigns names as browsers', async () => { browser: { enabled: true, headless: true, + provider: preview(), instances: [ { browser: 'chromium' }, { browser: 'firefox' }, @@ -39,6 +40,7 @@ test('filters projects', async () => { const { projects } = await vitest({ project: 'chromium' }, { browser: { enabled: true, + provider: preview(), instances: [ { browser: 'chromium' }, { browser: 'firefox' }, @@ -55,6 +57,7 @@ test('filters projects with a wildcard', async () => { const { projects } = await vitest({ project: 'chrom*' }, { browser: { enabled: true, + provider: preview(), instances: [ { browser: 'chromium' }, { browser: 'firefox' }, @@ -76,6 +79,7 @@ test('assigns names as browsers in a custom project', async () => { browser: { enabled: true, headless: true, + provider: preview(), instances: [ { browser: 'chromium' }, { browser: 'firefox' }, @@ -103,6 +107,7 @@ test('inherits browser options', async () => { } as any, browser: { enabled: true, + provider: preview(), headless: true, screenshotFailures: false, testerHtmlPath: '/custom-path.html', @@ -244,6 +249,7 @@ test('browser instances with include/exclude/includeSource option override paren includeSource: ['src/**/*.{js,ts}'], browser: { enabled: true, + provider: preview(), headless: true, instances: [ { browser: 'chromium' }, @@ -299,6 +305,7 @@ test('browser instances with empty include array should get parent include patte include: ['**/*.test.{js,ts}'], browser: { enabled: true, + provider: preview(), headless: true, instances: [ { browser: 'chromium', include: [] }, @@ -349,6 +356,7 @@ test('can enable browser-cli options for multi-project workspace', async () => { { browser: { enabled: true, + provider: preview(), headless: true, instances: [], }, @@ -391,6 +399,7 @@ test('core provider has no options if `provider` is not set', async () => { const v = await vitest({}, { browser: { enabled: true, + provider: preview(), instances: [{ browser: 'chromium', }], @@ -512,6 +521,7 @@ test('provider options can be changed dynamically in CLI', async () => { }, { browser: { enabled: true, + provider: preview(), instances: [ { browser: 'chromium' }, ], @@ -785,6 +795,7 @@ describe('[e2e] workspace configs are affected by the CLI options', () => { name: 'browser', browser: { enabled: true, + provider: preview(), instances: [], }, }, @@ -839,6 +850,7 @@ describe('[e2e] workspace configs are affected by the CLI options', () => { name: 'browser', browser: { enabled: true, + provider: preview(), instances: [], }, }, diff --git a/test/dts-playwright/src/basic.test.ts b/test/dts-playwright/src/basic.test.ts index 4aec7d2dbe94..079bc3674ef9 100644 --- a/test/dts-playwright/src/basic.test.ts +++ b/test/dts-playwright/src/basic.test.ts @@ -1,5 +1,5 @@ -import { page, userEvent } from '@vitest/browser/context' import { test } from 'vitest' +import { page, userEvent } from 'vitest/browser' test('basic', async () => { document.body.innerHTML = `` From 8025543148d82bbe71848cde509ac280ff6f8460 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 16:10:14 +0200 Subject: [PATCH 14/43] fix: update the config checks --- .../browser-webdriverio/src/commands/screenshot.ts | 4 ++-- packages/browser/src/node/plugin.ts | 11 ++++++++--- packages/vitest/src/node/projects/resolveProjects.ts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/browser-webdriverio/src/commands/screenshot.ts b/packages/browser-webdriverio/src/commands/screenshot.ts index 1d090c6f3ce8..a8fa1103f3df 100644 --- a/packages/browser-webdriverio/src/commands/screenshot.ts +++ b/packages/browser-webdriverio/src/commands/screenshot.ts @@ -1,9 +1,9 @@ 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 { nanoid } from '@vitest/utils/helpers' import { dirname, normalize, resolve } from 'pathe' interface ScreenshotCommandOptions extends Omit { @@ -46,7 +46,7 @@ export async function takeScreenshot( // webdriverio needs a path, so if one is not already set we create a temporary one if (savePath === undefined) { - savePath = resolve(context.project.tmpDir, nanoid()) + savePath = resolve(context.project.tmpDir, crypto.randomUUID()) await mkdir(context.project.tmpDir, { recursive: true }) } diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index f65aa2ce2b46..91fbb991ff35 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -232,9 +232,9 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { const exclude = [ 'vitest', + 'vitest/browser', 'vitest/internal/browser', 'vitest/runners', - '@vitest/browser', '@vitest/browser/client', '@vitest/utils', '@vitest/utils/source-map', @@ -282,10 +282,15 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { 'vitest > expect-type', 'vitest > @vitest/snapshot > magic-string', 'vitest > @vitest/expect > chai', - '@vitest/browser > @testing-library/user-event', - '@vitest/browser > @testing-library/dom', ] + if (parentServer.config.browser.provider?.name === 'preview') { + include.push( + '@vitest/browser-preview > @testing-library/user-event', + '@vitest/browser-preview > @testing-library/dom', + ) + } + const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : project.config.root const svelte = isPackageExists('vitest-browser-svelte', fileRoot) diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index eac7449c64cb..cf6243cc9b76 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -222,7 +222,7 @@ export async function resolveBrowserProjects( if (name == null) { throw new Error(`The browser configuration must have a "name" property. This is a bug in Vitest. Please, open a new issue with reproduction`) } - if (config.provider?.name !== project.config.browser.provider?.name) { + if (config.provider?.name != null && config.provider?.name !== project.config.browser.provider?.name) { throw new Error(`The instance cannot have a different provider from its parent. The "${name}" instance specifies "${config.provider?.name}" provider, but its parent has a "${project.config.browser.provider?.name}" provider.`) } From ffef3e96d6aed90478f9599eecf1692bc3a51a78 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 16:14:47 +0200 Subject: [PATCH 15/43] fix: update startupTime calculations --- packages/browser/src/client/client.ts | 2 +- packages/browser/src/client/orchestrator.ts | 12 ++++++++---- packages/vitest/src/node/pools/browser.ts | 3 --- packages/vitest/src/types/browser.ts | 1 - 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 1606fad5b86c..9e5b72905440 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -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() } diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 213871de2177..06e73d2d6ed5 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -29,6 +29,8 @@ export class IframeOrchestrator { } public async createTesters(options: BrowserTesterOptions): Promise { + 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({ diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index abf24d3e6b0c..a6b277c56daf 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -6,7 +6,6 @@ import type { TestSpecification } from '../spec' import type { BrowserProvider } from '../types/browser' import crypto from 'node:crypto' import * as nodeos from 'node:os' -import { performance } from 'node:perf_hooks' import { createDefer } from '@vitest/utils/helpers' import { stringify } from 'flatted' import { createDebugger } from '../../utils/debugger' @@ -308,7 +307,6 @@ class BrowserPool { if (!this._promise) { throw new Error(`Unexpected empty queue`) } - const startTime = performance.now() const orchestrator = this.getOrchestrator(sessionId) debug?.('[%s] run test %s', sessionId, file) @@ -322,7 +320,6 @@ class BrowserPool { // this will be parsed by the test iframe, not the orchestrator // so we need to stringify it first to avoid double serialization providedContext: this._providedContext || '[{}]', - startTime, }, ) .then(() => { diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 4e124e023c75..11d7c2122425 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -4,5 +4,4 @@ export interface BrowserTesterOptions { method: TestExecutionMethod files: string[] providedContext: string - startTime: number } From a5756a0af7ac06840733f0337c1369edd0f70ccd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Sep 2025 16:17:31 +0200 Subject: [PATCH 16/43] test: update test setup --- test/cli/test/fails.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/test/cli/test/fails.test.ts b/test/cli/test/fails.test.ts index 6d4ea8255883..664c0a1c4072 100644 --- a/test/cli/test/fails.test.ts +++ b/test/cli/test/fails.test.ts @@ -109,16 +109,6 @@ it('prints a warning if the assertion is not awaited', async () => { it('prints a warning if the assertion is not awaited in the browser mode', async () => { const { stderr } = await runInlineTests({ - './vitest.config.js': { - test: { - browser: { - enabled: true, - instances: [{ browser: 'chromium' }], - provider: playwright(), - headless: true, - }, - }, - }, 'base.test.js': ts` import { expect, test } from 'vitest'; @@ -126,6 +116,15 @@ it('prints a warning if the assertion is not awaited in the browser mode', async expect(Promise.resolve(1)).resolves.toBe(1) }) `, + }, {}, {}, { + test: { + browser: { + enabled: true, + instances: [{ browser: 'chromium' }], + provider: playwright(), + headless: true, + }, + }, }) expect(stderr).toContain('Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited') expect(stderr).toContain('base.test.js:5:33') From 97ae383841da74ce817f83a133638de75451309d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 30 Sep 2025 14:55:05 +0200 Subject: [PATCH 17/43] fix: don't keep vitest/browser code in vitest --- packages/browser-playwright/context.d.ts | 1 + packages/browser-playwright/package.json | 4 + packages/browser-playwright/src/playwright.ts | 10 +- packages/browser-preview/context.d.ts | 1 + packages/browser-preview/package.json | 3 + packages/browser-webdriverio/context.d.ts | 1 + packages/browser-webdriverio/package.json | 3 + .../browser-webdriverio/src/webdriverio.ts | 8 +- packages/{vitest => }/browser/aria-role.d.ts | 0 packages/browser/context.d.ts | 768 +++++++++++++++++ packages/browser/context.js | 19 + packages/{vitest => }/browser/jest-dom.d.ts | 10 +- packages/{vitest => }/browser/matchers.d.ts | 2 +- packages/browser/package.json | 2 +- packages/browser/src/node/commands/fs.ts | 2 +- .../browser/src/node/commands/screenshot.ts | 2 +- .../node/commands/screenshotMatcher/types.ts | 16 + .../node/commands/screenshotMatcher/utils.ts | 2 +- packages/browser/src/node/commands/utils.ts | 2 +- packages/browser/src/node/project.ts | 2 +- packages/browser/utils.d.ts | 2 +- packages/vitest/browser/context.d.ts | 774 +----------------- packages/vitest/package.json | 13 +- pnpm-lock.yaml | 10 +- 24 files changed, 864 insertions(+), 793 deletions(-) create mode 100644 packages/browser-playwright/context.d.ts create mode 100644 packages/browser-preview/context.d.ts create mode 100644 packages/browser-webdriverio/context.d.ts rename packages/{vitest => }/browser/aria-role.d.ts (100%) create mode 100644 packages/browser/context.d.ts create mode 100644 packages/browser/context.js rename packages/{vitest => }/browser/jest-dom.d.ts (99%) rename packages/{vitest => }/browser/matchers.d.ts (95%) diff --git a/packages/browser-playwright/context.d.ts b/packages/browser-playwright/context.d.ts new file mode 100644 index 000000000000..a022f2f10456 --- /dev/null +++ b/packages/browser-playwright/context.d.ts @@ -0,0 +1 @@ +export * from '@vitest/browser/context' diff --git a/packages/browser-playwright/package.json b/packages/browser-playwright/package.json index 2614d2c3415a..29f6c2410ab3 100644 --- a/packages/browser-playwright/package.json +++ b/packages/browser-playwright/package.json @@ -20,12 +20,16 @@ "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": { diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 08c85f88729c..803cf845ab90 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -1,5 +1,9 @@ /* eslint-disable ts/method-signature-style */ +import type { + ScreenshotComparatorRegistry, + ScreenshotMatcherOptions, +} from '@vitest/browser/context' import type { MockedModule } from '@vitest/mocker' import type { Browser, @@ -14,10 +18,6 @@ import type { import type { Protocol } from 'playwright-core/types/protocol' import type { SourceMap } from 'rollup' import type { ResolvedConfig } from 'vite' -import type { - ScreenshotComparatorRegistry, - ScreenshotMatcherOptions, -} from 'vitest/browser' import type { BrowserCommand, BrowserModuleMocker, @@ -567,7 +567,7 @@ type PWSelectOptions = NonNullable[2]> type PWDragAndDropOptions = NonNullable[2]> type PWSetInputFiles = NonNullable[2]> -declare module 'vitest/browser' { +declare module '@vitest/browser/context' { export interface UserEventHoverOptions extends PWHoverOptions {} export interface UserEventClickOptions extends PWClickOptions {} export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {} diff --git a/packages/browser-preview/context.d.ts b/packages/browser-preview/context.d.ts new file mode 100644 index 000000000000..a022f2f10456 --- /dev/null +++ b/packages/browser-preview/context.d.ts @@ -0,0 +1 @@ +export * from '@vitest/browser/context' diff --git a/packages/browser-preview/package.json b/packages/browser-preview/package.json index eee3fec8a839..7b3019168533 100644 --- a/packages/browser-preview/package.json +++ b/packages/browser-preview/package.json @@ -20,6 +20,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./context": { + "types": "./context.d.ts" + }, "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/browser-webdriverio/context.d.ts b/packages/browser-webdriverio/context.d.ts new file mode 100644 index 000000000000..a022f2f10456 --- /dev/null +++ b/packages/browser-webdriverio/context.d.ts @@ -0,0 +1 @@ +export * from '@vitest/browser/context' diff --git a/packages/browser-webdriverio/package.json b/packages/browser-webdriverio/package.json index e7b6fe56c984..09c23a6d49da 100644 --- a/packages/browser-webdriverio/package.json +++ b/packages/browser-webdriverio/package.json @@ -20,6 +20,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./context": { + "types": "./context.d.ts" + }, "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 4d756704a0a4..189c79a27097 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -1,8 +1,8 @@ -import type { Capabilities } from '@wdio/types' import type { ScreenshotComparatorRegistry, ScreenshotMatcherOptions, -} from 'vitest/browser' +} from '@vitest/browser/context' +import type { Capabilities } from '@wdio/types' import type { BrowserCommand, BrowserProvider, @@ -282,7 +282,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { } } -declare module 'vitest/node' { +declare module '@vitest/browser/context' { export interface UserEventClickOptions extends ClickOptions {} export interface UserEventDragOptions extends DragAndDropOptions { @@ -291,7 +291,9 @@ declare module 'vitest/node' { targetX?: number targetY?: number } +} +declare module 'vitest/node' { export interface BrowserCommandContext { browser: WebdriverIO.Browser } diff --git a/packages/vitest/browser/aria-role.d.ts b/packages/browser/aria-role.d.ts similarity index 100% rename from packages/vitest/browser/aria-role.d.ts rename to packages/browser/aria-role.d.ts diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts new file mode 100644 index 000000000000..cb23b7ed3de7 --- /dev/null +++ b/packages/browser/context.d.ts @@ -0,0 +1,768 @@ +import { SerializedConfig } from 'vitest' +import { ARIARole } from './aria-role.js' +import {} from './matchers.js' + +export type BufferEncoding = + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'utf-16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' + +export interface FsOptions { + encoding?: BufferEncoding + flag?: string | number +} + +export interface CDPSession { + // methods are defined by the provider type augmentation +} + +export interface ScreenshotOptions { + element?: Element | Locator + /** + * Path relative to the current test file. + * @default `__screenshots__/${testFileName}/${testName}.png` + */ + path?: string + /** + * Will also return the base64 encoded screenshot alongside the path. + */ + base64?: boolean + /** + * Keep the screenshot on the file system. If file is not saved, + * `page.screenshot` always returns `base64` screenshot. + * @default true + */ + save?: boolean +} + +export interface ScreenshotComparatorRegistry { + pixelmatch: { + /** + * The maximum number of pixels that are allowed to differ between the captured + * screenshot and the stored reference image. + * + * If set to `undefined`, any non-zero difference will cause the test to fail. + * + * For example, `allowedMismatchedPixels: 10` means the test will pass if 10 + * or fewer pixels differ, but fail if 11 or more differ. + * + * If both this and `allowedMismatchedPixelRatio` are set, the more restrictive + * value (i.e., fewer allowed mismatches) will be used. + * + * @default undefined + */ + allowedMismatchedPixels?: number | undefined + /** + * The maximum allowed ratio of differing pixels between the captured screenshot + * and the reference image. + * + * Must be a value between `0` and `1`. + * + * For example, `allowedMismatchedPixelRatio: 0.02` means the test will pass + * if up to 2% of pixels differ, but fail if more than 2% differ. + * + * If both this and `allowedMismatchedPixels` are set, the more restrictive + * value (i.e., fewer allowed mismatches) will be used. + * + * @default undefined + */ + allowedMismatchedPixelRatio?: number | undefined + /** + * Acceptable perceived color difference between the same pixel in two images. + * + * Value ranges from `0` (strict) to `1` (very lenient). Lower values mean + * small differences will be detected. + * + * The comparison uses the {@link https://en.wikipedia.org/wiki/YIQ | YIQ color space}. + * + * @default 0.1 + */ + threshold?: number | undefined + /** + * If `true`, disables detection and ignoring of anti-aliased pixels. + * + * @default false + */ + includeAA?: boolean | undefined + /** + * Blending level of unchanged pixels in the diff image. + * + * Ranges from `0` (white) to `1` (original brightness). + * + * @default 0.1 + */ + alpha?: number | undefined + /** + * Color used for anti-aliased pixels in the diff image. + * + * Format: `[R, G, B]` + * + * @default [255, 255, 0] + */ + aaColor?: [r: number, g: number, b: number] | undefined + /** + * Color used for differing pixels in the diff image. + * + * Format: `[R, G, B]` + * + * @default [255, 0, 0] + */ + diffColor?: [r: number, g: number, b: number] | undefined + /** + * Optional alternative color for dark-on-light differences, to help show + * what's added vs. removed. + * + * If not set, `diffColor` is used for all differences. + * + * Format: `[R, G, B]` + * + * @default undefined + */ + diffColorAlt?: [r: number, g: number, b: number] | undefined + /** + * If `true`, shows only the diff as a mask on a transparent background, + * instead of overlaying it on the original image. + * + * Anti-aliased pixels won't be shown (if detected). + * + * @default false + */ + diffMask?: boolean | undefined + } +} + +export interface ScreenshotMatcherOptions< + ComparatorName extends keyof ScreenshotComparatorRegistry = keyof ScreenshotComparatorRegistry +> { + /** + * The name of the comparator to use for visual diffing. + * + * Must be one of the keys from {@linkcode ScreenshotComparatorRegistry}. + * + * @defaultValue `'pixelmatch'` + */ + comparatorName?: ComparatorName + comparatorOptions?: ScreenshotComparatorRegistry[ComparatorName] + screenshotOptions?: Omit< + ScreenshotOptions, + 'element' | 'base64' | 'path' | 'save' | 'type' + > + /** + * Time to wait until a stable screenshot is found. + * + * Setting this value to `0` disables the timeout, but if a stable screenshot + * can't be determined the process will not end. + * + * @default 5000 + */ + timeout?: number +} + +export interface BrowserCommands { + readFile: ( + path: string, + options?: BufferEncoding | FsOptions + ) => Promise + writeFile: ( + path: string, + content: string, + options?: BufferEncoding | (FsOptions & { mode?: number | string }) + ) => Promise + removeFile: (path: string) => Promise +} + +export interface 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. + * + * **Note:** Unlike `@testing-library/user-event`, the default `userEvent` instance + * 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 + /** + * Cleans up the user event instance, releasing any resources or state it holds, + * such as keyboard press state. For the default `userEvent` instance, this method + * is automatically called after each test case. + */ + cleanup: () => Promise + /** + * Click on an element. Uses provider's API under the hood and supports all its options. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API + * @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API + */ + click: (element: Element | Locator, options?: UserEventClickOptions) => Promise + /** + * Triggers a double click event on an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-dblclick} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#dblClick} testing-library API + */ + dblClick: (element: Element | Locator, options?: UserEventDoubleClickOptions) => Promise + /** + * Triggers a triple click event on an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API: using `click` with `clickCount: 3` + * @see {@link https://webdriver.io/docs/api/browser/actions/} WebdriverIO API: using actions api with `move` plus three `down + up + pause` events in a row + * @see {@link https://testing-library.com/docs/user-event/convenience/#tripleclick} testing-library API + */ + tripleClick: (element: Element | Locator, options?: UserEventTripleClickOptions) => Promise + /** + * Choose one or more values from a select element. Uses provider's API under the hood. + * If select doesn't have `multiple` attribute, only the first value will be selected. + * @example + * await userEvent.selectOptions(select, 'Option 1') + * expect(select).toHaveValue('option-1') + * + * await userEvent.selectOptions(select, 'option-1') + * expect(select).toHaveValue('option-1') + * + * await userEvent.selectOptions(select, [ + * screen.getByRole('option', { name: 'Option 1' }), + * screen.getByRole('option', { name: 'Option 2' }), + * ]) + * expect(select).toHaveValue(['option-1', 'option-2']) + * @see {@link https://playwright.dev/docs/api/class-locator#locator-select-option} Playwright API + * @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions} testing-library API + */ + selectOptions: ( + element: Element, + values: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], + options?: UserEventSelectOptions, + ) => Promise + /** + * Type text on the keyboard. If any input is focused, it will receive the text, + * otherwise it will be typed on the document. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * @example + * await userEvent.keyboard('foo') // translates to: f, o, o + * await userEvent.keyboard('{{a[[') // translates to: {, a, [ + * await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API + */ + keyboard: (text: string) => Promise + /** + * Types text into an element. Uses provider's API under the hood. + * **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers. + * This method can be significantly slower than `userEvent.fill`, so it should be used only when necessary. + * @example + * await userEvent.type(input, 'foo') // translates to: f, o, o + * await userEvent.type(input, '{{a[[') // translates to: {, a, [ + * await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + * @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/action#key-input-source} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + type: (element: Element | Locator, text: string, options?: UserEventTypeOptions) => Promise + /** + * Removes all text from an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API + * @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API + */ + clear: (element: Element | Locator, options?: UserEventClearOptions) => Promise + /** + * Sends a `Tab` key event. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API + */ + tab: (options?: UserEventTabOptions) => Promise + /** + * Hovers over an element. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + hover: (element: Element | Locator, options?: UserEventHoverOptions) => Promise + /** + * Moves cursor position to the body element. Uses provider's API under the hood. + * By default, the cursor position is in the center (in webdriverio) or in some visible place (in playwright) + * of the body element, so if the current element is already there, this will have no effect. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API + * @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API + */ + unhover: (element: Element | Locator, options?: UserEventHoverOptions) => Promise + /** + * Change a file input element to have the specified files. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-set-input-files} Playwright API + * @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API + */ + upload: (element: Element | Locator, files: File | File[] | string | string[], options?: UserEventUploadOptions) => Promise + /** + * Copies the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#copy} testing-library API + */ + copy: () => Promise + /** + * Cuts the selected content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#cut} testing-library API + */ + cut: () => Promise + /** + * Pastes the copied or cut content. + * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API + * @see {@link https://webdriver.io/docs/api/browser/keys//} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/clipboard#paste} testing-library API + */ + paste: () => Promise + /** + * Fills an input element with text. This will remove any existing text in the input before typing the new text. + * Uses provider's API under the hood. + * This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`). + * @example + * await userEvent.fill(input, 'foo') // translates to: f, o, o + * await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [ + * await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, } + * @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API + * @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API + * @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API + */ + fill: (element: Element | Locator, text: string, options?: UserEventFillOptions) => Promise + /** + * Drags a source element on top of the target element. This API is not supported by "preview" provider. + * @see {@link https://playwright.dev/docs/api/class-frame#frame-drag-and-drop} Playwright API + * @see {@link https://webdriver.io/docs/api/element/dragAndDrop/} WebdriverIO API + */ + dragAndDrop: (source: Element | Locator, target: Element | Locator, options?: UserEventDragAndDropOptions) => Promise +} + +export interface UserEventFillOptions {} +export interface UserEventHoverOptions {} +export interface UserEventSelectOptions {} +export interface UserEventClickOptions {} +export interface UserEventClearOptions {} +export interface UserEventDoubleClickOptions {} +export interface UserEventTripleClickOptions {} +export interface UserEventDragAndDropOptions {} +export interface UserEventUploadOptions {} + +export interface LocatorOptions { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a + * regular expression. Note that exact match still trims whitespace. + */ + exact?: boolean +} + +export interface LocatorByRoleOptions extends LocatorOptions { + /** + * Should checked elements (set by `aria-checked` or ``) be included or not. By default, the filter is not applied. + * + * See [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked) for more information + */ + checked?: boolean + /** + * Should disabled elements be included or not. By default, the filter is not applied. Note that unlike other attributes, `disable` state is inherited. + * + * See [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled) for more information + */ + disabled?: boolean + /** + * Should expanded elements be included or not. By default, the filter is not applied. + * + * See [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded) for more information + */ + expanded?: boolean + /** + * Should elements that are [normally excluded](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion) from the accessibility tree be queried. By default, only non-hidden elements are matched by role selector. + * + * Note that roles `none` and `presentation` are always included. + * @default false + */ + includeHidden?: boolean + /** + * A number attribute that is usually present for `heading`, `listitem`, `row`, `treeitem` roles with default values for `

-

` elements. By default, the filter is not applied. + * + * See [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level) for more information + */ + level?: number + /** + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. + */ + name?: string | RegExp + /** + * Should pressed elements be included or not. By default, the filter is not applied. + * + * See [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed) for more information + */ + pressed?: boolean + /** + * Should selected elements be included or not. By default, the filter is not applied. + * + * See [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected) for more information + */ + selected?: boolean +} + +interface LocatorScreenshotOptions extends Omit {} + +interface LocatorSelectors { + /** + * Creates a way to locate an element by its [ARIA role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles), [ARIA attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes) and [accessible name](https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name). + * @see {@link https://vitest.dev/guide/browser/locators#getbyrole} + */ + getByRole: (role: ARIARole | ({} & string), options?: LocatorByRoleOptions) => Locator + /** + * @see {@link https://vitest.dev/guide/browser/locators#getbylabeltext} + */ + getByLabelText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element with an `alt` attribute that matches the text. Unlike testing-library's implementation, Vitest will match any element that has an `alt` attribute. + * @see {@link https://vitest.dev/guide/browser/locators#getbyalttext} + */ + getByAltText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that has the specified placeholder text. Vitest will match any element that has a matching `placeholder` attribute, not just `input`. + * @see {@link https://vitest.dev/guide/browser/locators#getbyplaceholder} + */ + getByPlaceholder: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that contains the specified text. The text will be matched against TextNode's [`nodeValue`](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue) or input's value if the type is `button` or `reset`. + * Matching by text always normalizes whitespace, even with exact match. + * For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace. + * @see {@link https://vitest.dev/guide/browser/locators#getbytext} + */ + getByText: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that has the specified `title` attribute. Unlike testing-library's `getByTitle`, Vitest cannot find `title` elements within an SVG. + * @see {@link https://vitest.dev/guide/browser/locators#getbytitle} + */ + getByTitle: (text: string | RegExp, options?: LocatorOptions) => Locator + /** + * Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/config/#browser-locators-testidattribute). + * @see {@link https://vitest.dev/guide/browser/locators#getbytestid} + */ + getByTestId: (text: string | RegExp) => Locator +} + +export interface FrameLocator extends LocatorSelectors {} + +export interface Locator extends LocatorSelectors { + /** + * Selector string that will be used to locate the element by the browser provider. + * You can use this string in the commands API: + * ```ts + * // playwright + * function test({ selector, iframe }) { + * await iframe.locator(selector).click() + * } + * // webdriverio + * function test({ selector, browser }) { + * await browser.$(selector).click() + * } + * ``` + * @see {@link https://vitest.dev/guide/browser/locators#selector} + */ + readonly selector: string + + /** + * The number of elements that this locator is matching. + * @see {@link https://vitest.dev/guide/browser/locators#length} + */ + readonly length: number + + /** + * Click on an element. You can use the options to set the cursor position. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-click} + */ + click(options?: UserEventClickOptions): Promise + /** + * Triggers a double click event on an element. You can use the options to set the cursor position. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dblclick} + */ + dblClick(options?: UserEventDoubleClickOptions): Promise + /** + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-tripleclick} + */ + tripleClick(options?: UserEventTripleClickOptions): Promise + /** + * Clears the input element content + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear} + */ + clear(options?: UserEventClearOptions): Promise + /** + * Moves the cursor position to the selected element + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover} + */ + hover(options?: UserEventHoverOptions): Promise + /** + * This works the same as `locator.hover`, but moves the cursor to the `document.body` element instead. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-unhover} + */ + unhover(options?: UserEventHoverOptions): Promise + /** + * Sets the value of the current `input`, `textarea` or `contenteditable` element. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-fill} + */ + fill(text: string, options?: UserEventFillOptions): Promise + /** + * Drags the current element to the target location. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dropto} + */ + dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise + /** + * Choose one or more values from a ``) be included or not. By default, the filter is not applied. - * - * See [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked) for more information - */ - checked?: boolean - /** - * Should disabled elements be included or not. By default, the filter is not applied. Note that unlike other attributes, `disable` state is inherited. - * - * See [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled) for more information - */ - disabled?: boolean - /** - * Should expanded elements be included or not. By default, the filter is not applied. - * - * See [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded) for more information - */ - expanded?: boolean - /** - * Should elements that are [normally excluded](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion) from the accessibility tree be queried. By default, only non-hidden elements are matched by role selector. - * - * Note that roles `none` and `presentation` are always included. - * @default false - */ - includeHidden?: boolean - /** - * A number attribute that is usually present for `heading`, `listitem`, `row`, `treeitem` roles with default values for `

-

` elements. By default, the filter is not applied. - * - * See [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level) for more information - */ - level?: number - /** - * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is - * case-insensitive and searches for a substring, use `exact` to control this behavior. - */ - name?: string | RegExp - /** - * Should pressed elements be included or not. By default, the filter is not applied. - * - * See [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed) for more information - */ - pressed?: boolean - /** - * Should selected elements be included or not. By default, the filter is not applied. - * - * See [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected) for more information - */ - selected?: boolean -} - -interface LocatorScreenshotOptions extends Omit {} - -interface LocatorSelectors { - /** - * Creates a way to locate an element by its [ARIA role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles), [ARIA attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes) and [accessible name](https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name). - * @see {@link https://vitest.dev/guide/browser/locators#getbyrole} - */ - getByRole: (role: ARIARole | ({} & string), options?: LocatorByRoleOptions) => Locator - /** - * @see {@link https://vitest.dev/guide/browser/locators#getbylabeltext} - */ - getByLabelText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element with an `alt` attribute that matches the text. Unlike testing-library's implementation, Vitest will match any element that has an `alt` attribute. - * @see {@link https://vitest.dev/guide/browser/locators#getbyalttext} - */ - getByAltText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that has the specified placeholder text. Vitest will match any element that has a matching `placeholder` attribute, not just `input`. - * @see {@link https://vitest.dev/guide/browser/locators#getbyplaceholder} - */ - getByPlaceholder: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that contains the specified text. The text will be matched against TextNode's [`nodeValue`](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue) or input's value if the type is `button` or `reset`. - * Matching by text always normalizes whitespace, even with exact match. - * For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace. - * @see {@link https://vitest.dev/guide/browser/locators#getbytext} - */ - getByText: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that has the specified `title` attribute. Unlike testing-library's `getByTitle`, Vitest cannot find `title` elements within an SVG. - * @see {@link https://vitest.dev/guide/browser/locators#getbytitle} - */ - getByTitle: (text: string | RegExp, options?: LocatorOptions) => Locator - /** - * Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/config/#browser-locators-testidattribute). - * @see {@link https://vitest.dev/guide/browser/locators#getbytestid} - */ - getByTestId: (text: string | RegExp) => Locator -} - -export interface FrameLocator extends LocatorSelectors {} - -export interface Locator extends LocatorSelectors { - /** - * Selector string that will be used to locate the element by the browser provider. - * You can use this string in the commands API: - * ```ts - * // playwright - * function test({ selector, iframe }) { - * await iframe.locator(selector).click() - * } - * // webdriverio - * function test({ selector, browser }) { - * await browser.$(selector).click() - * } - * ``` - * @see {@link https://vitest.dev/guide/browser/locators#selector} - */ - readonly selector: string - - /** - * The number of elements that this locator is matching. - * @see {@link https://vitest.dev/guide/browser/locators#length} - */ - readonly length: number - - /** - * Click on an element. You can use the options to set the cursor position. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-click} - */ - click(options?: UserEventClickOptions): Promise - /** - * Triggers a double click event on an element. You can use the options to set the cursor position. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dblclick} - */ - dblClick(options?: UserEventDoubleClickOptions): Promise - /** - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-tripleclick} - */ - tripleClick(options?: UserEventTripleClickOptions): Promise - /** - * Clears the input element content - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear} - */ - clear(options?: UserEventClearOptions): Promise - /** - * Moves the cursor position to the selected element - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover} - */ - hover(options?: UserEventHoverOptions): Promise - /** - * This works the same as `locator.hover`, but moves the cursor to the `document.body` element instead. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-unhover} - */ - unhover(options?: UserEventHoverOptions): Promise - /** - * Sets the value of the current `input`, `textarea` or `contenteditable` element. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-fill} - */ - fill(text: string, options?: UserEventFillOptions): Promise - /** - * Drags the current element to the target location. - * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-dropto} - */ - dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise - /** - * Choose one or more values from a `