diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 05bc06559a08..998f9a865c1d 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -236,6 +236,11 @@ export const page: { * Change the size of iframe's viewport. */ viewport: (width: number | string, height: number | string) => Promise + /** + * Make a screenshot of the test iframe or a specific element. + * @returns Path to the screenshot file. + */ + screenshot: (options?: ScreenshotOptions) => Promise } ``` @@ -360,6 +365,63 @@ declare module '@vitest/browser/context' { Custom functions will override built-in ones if they have the same name. ::: +### Custom `playwright` commands + +Vitest exposes several `playwright` specific properties on the command context. + +- `page` references the full page that contains the test iframe. This is the orchestrator HTML and you most likely shouldn't touch it to not break things. +- `tester` is the iframe locator. The API is pretty limited here, but you can chain it further to access your HTML elements. +- `body` is the iframe's `body` locator that exposes more Playwright APIs. + +```ts +import { defineCommand } from '@vitest/browser' + +export const myCommand = defineCommand(async (ctx, arg1, arg2) => { + if (ctx.provider.name === 'playwright') { + const element = await ctx.tester.findByRole('alert') + const screenshot = await element.screenshot() + // do something with the screenshot + return difference + } +}) +``` + +::: tip +If you are using TypeScript, don't forget to add `@vitest/browser/providers/playwright` to your `tsconfig` "compilerOptions.types" field to get autocompletion: + +```json +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/playwright" + ] + } +} +``` +::: + +### Custom `webdriverio` commands + +Vitest exposes some `webdriverio` specific properties on the context object. + +- `browser` is the `WebdriverIO.Browser` API. + +Vitest automatically switches the `webdriver` context to the test iframe by calling `browser.switchToFrame` before the command is called, so `$` and `$$` methods refer to the elements inside the iframe, not in the orchestrator, but non-webdriver APIs will still refer to the parent frame context. + +::: tip +If you are using TypeScript, don't forget to add `@vitest/browser/providers/webdriverio` to your `tsconfig` "compilerOptions.types" field to get autocompletion: + +```json +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/webdriverio" + ] + } +} +``` +::: + ## Limitations ### Thread Blocking Dialogs diff --git a/eslint.config.js b/eslint.config.js index 08f726ec8e9f..6d1d64076e75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default antfu( // let TypeScript handle this 'no-undef': 'off', 'ts/no-invalid-this': 'off', + 'eslint-comments/no-unlimited-disable': 'off', // TODO: migrate and turn it back on 'ts/ban-types': 'off', diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 44c9951d75d3..fcd268255528 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -26,6 +26,14 @@ export interface UpPayload { up: string } export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload +export interface ScreenshotOptions { + element?: Element + /** + * Path relative to the `screenshotDirectory` in the test config. + */ + path?: string +} + export interface BrowserCommands { readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise @@ -100,7 +108,7 @@ export const userEvent: UserEvent */ export const commands: BrowserCommands -export const page: { +export interface BrowserPage { /** * Serialized test config. */ @@ -109,4 +117,11 @@ export const page: { * Change the size of iframe's viewport. */ viewport: (width: number, height: number) => Promise + /** + * Make a screenshot of the test iframe or a specific element. + * @returns Path to the screenshot file. + */ + screenshot: (options?: ScreenshotOptions) => Promise } + +export const page: BrowserPage diff --git a/packages/browser/package.json b/packages/browser/package.json index 81a57fb0b3be..1d607dfeb9d5 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -73,6 +73,7 @@ "@testing-library/user-event": "^14.5.2", "@vitest/utils": "workspace:*", "magic-string": "^0.30.10", + "msw": "^2.3.1", "sirv": "^2.0.4" }, "devDependencies": { @@ -83,6 +84,7 @@ "@wdio/protocols": "^8.38.0", "birpc": "0.2.17", "flatted": "^3.3.1", + "pathe": "^1.1.2", "periscopic": "^4.0.2", "playwright": "^1.44.1", "playwright-core": "^1.44.1", diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index 0c2c99266b27..c74a5841cc99 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -1,8 +1,20 @@ -import type { Browser, LaunchOptions } from 'playwright' +import type { + BrowserContextOptions, + FrameLocator, + LaunchOptions, + Locator, + Page, +} from 'playwright' declare module 'vitest/node' { interface BrowserProviderOptions { launch?: LaunchOptions - page?: Parameters[0] + context?: Omit + } + + export interface BrowserCommandContext { + page: Page + tester: FrameLocator + body: Locator } } diff --git a/packages/browser/providers/webdriverio.d.ts b/packages/browser/providers/webdriverio.d.ts index 06550f11162a..890a878c8e6e 100644 --- a/packages/browser/providers/webdriverio.d.ts +++ b/packages/browser/providers/webdriverio.d.ts @@ -2,4 +2,8 @@ import type { RemoteOptions } from 'webdriverio' declare module 'vitest/node' { interface BrowserProviderOptions extends RemoteOptions {} + + export interface BrowserCommandContext { + browser: WebdriverIO.Browser + } } diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 910c332a6996..f6327813e599 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -4,6 +4,7 @@ import dts from 'rollup-plugin-dts' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' +import { defineConfig } from 'rollup' const require = createRequire(import.meta.url) const pkg = require('./package.json') @@ -32,7 +33,7 @@ const input = { providers: './src/node/providers/index.ts', } -export default () => [ +export default () => defineConfig([ { input, output: { @@ -42,6 +43,18 @@ export default () => [ external, plugins, }, + { + input: './src/client/context.ts', + output: { + file: 'dist/context.js', + format: 'esm', + }, + plugins: [ + esbuild({ + target: 'node18', + }), + ], + }, { input: input.index, output: { @@ -51,4 +64,4 @@ export default () => [ external, plugins: [dts()], }, -] +]) diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts new file mode 100644 index 000000000000..b2d894248006 --- /dev/null +++ b/packages/browser/src/client/channel.ts @@ -0,0 +1,90 @@ +import { getBrowserState } from './utils' + +export interface IframeDoneEvent { + type: 'done' + filenames: string[] + id: string +} + +export interface IframeErrorEvent { + type: 'error' + error: any + errorType: string + files: string[] + id: string +} + +export interface IframeViewportEvent { + type: 'viewport' + width: number + height: number + id: string +} + +export interface IframeMockEvent { + type: 'mock' + paths: string[] + mock: string | undefined | null +} + +export interface IframeUnmockEvent { + type: 'unmock' + paths: string[] +} + +export interface IframeMockingDoneEvent { + type: 'mock:done' | 'unmock:done' +} + +export interface IframeMockFactoryRequestEvent { + type: 'mock-factory:request' + id: string +} + +export interface IframeMockFactoryResponseEvent { + type: 'mock-factory:response' + exports: string[] +} + +export interface IframeMockFactoryErrorEvent { + type: 'mock-factory:error' + error: any +} + +export interface IframeViewportChannelEvent { + type: 'viewport:done' | 'viewport:fail' +} + +export interface IframeMockInvalidateEvent { + type: 'mock:invalidate' +} + +export type IframeChannelIncomingEvent = + | IframeViewportEvent + | IframeErrorEvent + | IframeDoneEvent + | IframeMockEvent + | IframeUnmockEvent + | IframeMockFactoryResponseEvent + | IframeMockFactoryErrorEvent + | IframeMockInvalidateEvent + +export type IframeChannelOutgoingEvent = + | IframeMockFactoryRequestEvent + | IframeViewportChannelEvent + | IframeMockingDoneEvent + +export type IframeChannelEvent = + | IframeChannelIncomingEvent + | IframeChannelOutgoingEvent + +export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`) + +export function waitForChannel(event: IframeChannelOutgoingEvent['type']) { + return new Promise((resolve) => { + channel.addEventListener('message', (e) => { + if (e.data?.type === event) + resolve() + }, { once: true }) + }) +} diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index df2ff58cdd56..12f19d673ef7 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -9,7 +9,9 @@ const PAGE_TYPE = getBrowserState().type export const PORT = import.meta.hot ? '51204' : location.port export const HOST = [location.hostname, PORT].filter(Boolean).join(':') -export const SESSION_ID = crypto.randomUUID() +export const SESSION_ID = PAGE_TYPE === 'orchestrator' + ? getBrowserState().contextId + : crypto.randomUUID() export const ENTRY_URL = `${ location.protocol === 'https:' ? 'wss:' : 'ws:' }//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&sessionId=${SESSION_ID}` @@ -25,7 +27,7 @@ export interface VitestBrowserClient { waitForConnection: () => Promise } -type BrowserRPC = BirpcReturn +export type BrowserRPC = BirpcReturn function createClient() { const autoReconnect = true @@ -120,4 +122,5 @@ function createClient() { } export const client = createClient() -export const channel = new BroadcastChannel('vitest') + +export { channel, waitForChannel } from './channel' diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts new file mode 100644 index 000000000000..675644242ac1 --- /dev/null +++ b/packages/browser/src/client/context.ts @@ -0,0 +1,110 @@ +import type { Task, WorkerGlobalState } from 'vitest' +import type { BrowserPage, UserEvent, UserEventClickOptions } from '../../context' +import type { BrowserRPC } from './client' +import type { BrowserRunnerState } from './utils' + +// this file should not import anything directly, only types + +function convertElementToXPath(element: Element) { + if (!element || !(element instanceof Element)) + throw new Error(`Expected DOM element to be an instance of Element, received ${typeof element}`) + + return getPathTo(element) +} + +function getPathTo(element: Element): string { + if (element.id !== '') + return `id("${element.id}")` + + if (!element.parentNode || element === document.documentElement) + return element.tagName + + let ix = 0 + const siblings = element.parentNode.childNodes + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i] + if (sibling === element) + return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${ix + 1}]` + if (sibling.nodeType === 1 && (sibling as Element).tagName === element.tagName) + ix++ + } + return 'invalid xpath' +} + +// @ts-expect-error not typed global +const state = (): WorkerGlobalState => __vitest_worker__ +// @ts-expect-error not typed global +const runner = (): BrowserRunnerState => __vitest_browser_runner__ +const filepath = () => state().filepath || state().current?.file?.filepath || undefined +const rpc = () => state().rpc as any as BrowserRPC +const contextId = runner().contextId +const channel = new BroadcastChannel(`vitest:${contextId}`) + +function triggerCommand(command: string, ...args: any[]) { + return rpc().triggerCommand(contextId, command, filepath(), args) +} + +export const userEvent: UserEvent = { + click(element: Element, options: UserEventClickOptions = {}) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_click', xpath, options) + }, +} + +const screenshotIds: Record> = {} +export const page: BrowserPage = { + get config() { + return runner().config + }, + viewport(width, height) { + const id = runner().iframeId + channel.postMessage({ type: 'viewport', width, height, id }) + return new Promise((resolve, reject) => { + channel.addEventListener('message', function handler(e) { + if (e.data.type === 'viewport:done' && e.data.id === id) { + channel.removeEventListener('message', handler) + resolve() + } + if (e.data.type === 'viewport:fail' && e.data.id === id) { + channel.removeEventListener('message', handler) + reject(new Error(e.data.error)) + } + }) + }) + }, + async screenshot(options = {}) { + const currentTest = state().current + if (!currentTest) + throw new Error('Cannot take a screenshot outside of a test.') + + if (currentTest.concurrent) { + throw new Error( + 'Cannot take a screenshot in a concurrent test because ' + + 'concurrent tests run at the same time in the same iframe and affect each other\'s environment. ' + + 'Use a non-concurrent test to take a screenshot.', + ) + } + + const repeatCount = currentTest.result?.repeatCount ?? 0 + const taskName = getTaskFullName(currentTest) + const number = screenshotIds[repeatCount]?.[taskName] ?? 1 + + screenshotIds[repeatCount] ??= {} + screenshotIds[repeatCount][taskName] = number + 1 + + const name = options.path || `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png` + + return triggerCommand( + '__vitest_screenshot', + name, + { + ...options, + element: options.element ? convertElementToXPath(options.element) : undefined, + }, + ) + }, +} + +function getTaskFullName(task: Task): string { + return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name +} diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index c76c5c218b18..61c5a4c82104 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,7 +1,9 @@ import { getType } from '@vitest/utils' -import { extname } from 'pathe' +import { extname, join } from 'pathe' import { rpc } from './rpc' -import { getBrowserState } from './utils' +import { getBrowserState, importId } from './utils' +import { channel, waitForChannel } from './client' +import type { IframeChannelOutgoingEvent } from './channel' const now = Date.now @@ -11,11 +13,35 @@ interface SpyModule { export class VitestBrowserClientMocker { private queue = new Set>() - private mocks: Record = {} + private mocks: Record = {} + private mockObjects: Record = {} private factories: Record any> = {} + private ids = new Set() private spyModule!: SpyModule + setupWorker() { + channel.addEventListener('message', async (e: MessageEvent) => { + if (e.data.type === 'mock-factory:request') { + try { + const module = await this.resolve(e.data.id) + const exports = Object.keys(module) + channel.postMessage({ + type: 'mock-factory:response', + exports, + }) + } + catch (err: any) { + const { processError } = await importId('vitest/browser') as typeof import('vitest/browser') + channel.postMessage({ + type: 'mock-factory:error', + error: processError(err), + }) + } + } + }) + } + public setSpyModule(mod: SpyModule) { this.spyModule = mod } @@ -25,8 +51,7 @@ export class VitestBrowserClientMocker { if (resolved == null) throw new Error(`[vitest] Cannot resolve ${id} imported from ${importer}`) const ext = extname(resolved.id) - const base = getBrowserState().config.base || '/' - const url = new URL(`/@id${base}${resolved.id}`, location.href) + const url = new URL(`/@id/${resolved.id}`, location.href) const query = `_vitest_original&ext.${ext}` const actualUrl = `${url.pathname}${ url.search ? `${url.search}&${query}` : `?${query}` @@ -36,7 +61,7 @@ export class VitestBrowserClientMocker { public async importMock(rawId: string, importer: string) { await this.prepare() - const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer) + const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer, false) const factoryReturn = this.get(resolvedId) if (factoryReturn) @@ -45,12 +70,11 @@ export class VitestBrowserClientMocker { if (this.factories[resolvedId]) return await this.resolve(resolvedId) - const base = getBrowserState().config.base || '/' if (type === 'redirect') { - const url = new URL(`/@id${base}${mockPath}`, location.href) + const url = new URL(`/@id/${mockPath}`, location.href) return import(url.toString()) } - const url = new URL(`/@id${base}${resolvedId}`, location.href) + const url = new URL(`/@id/${resolvedId}`, location.href) const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}` const moduleObject = await import(`${url.pathname}${query}${url.hash}`) return this.mockObject(moduleObject) @@ -61,7 +85,19 @@ export class VitestBrowserClientMocker { } public get(id: string) { - return this.mocks[id] + return this.mockObjects[id] + } + + public async invalidate() { + const ids = Array.from(this.ids) + if (!ids.length) + return + await rpc().invalidate(ids) + channel.postMessage({ type: 'mock:invalidate' }) + this.ids.clear() + this.mocks = {} + this.mockObjects = {} + this.factories = {} } public async resolve(id: string) { @@ -69,8 +105,8 @@ export class VitestBrowserClientMocker { if (!factory) throw new Error(`Cannot resolve ${id} mock: no factory provided`) try { - this.mocks[id] = await factory() - return this.mocks[id] + this.mockObjects[id] = await factory() + return this.mockObjects[id] } catch (err) { const vitestError = new Error( @@ -84,9 +120,23 @@ export class VitestBrowserClientMocker { } public queueMock(id: string, importer: string, factory?: () => any) { - const promise = rpc().queueMock(id, importer, !!factory) - .then((id) => { - this.factories[id] = factory! + const promise = rpc().resolveMock(id, importer, !!factory) + .then(async ({ mockPath, resolvedId }) => { + this.ids.add(resolvedId) + const urlPaths = resolveMockPaths(resolvedId) + const resolvedMock = typeof mockPath === 'string' + ? new URL(resolvedMockedPath(mockPath), location.href).toString() + : mockPath + urlPaths.forEach((url) => { + this.mocks[url] = resolvedMock + this.factories[url] = factory! + }) + channel.postMessage({ + type: 'mock', + paths: urlPaths, + mock: resolvedMock, + }) + await waitForChannel('mock:done') }).finally(() => { this.queue.delete(promise) }) @@ -94,10 +144,24 @@ export class VitestBrowserClientMocker { } public queueUnmock(id: string, importer: string) { - const promise = rpc().queueUnmock(id, importer) - .then((id) => { - delete this.factories[id] - }).finally(() => { + const promise = rpc().resolveId(id, importer) + .then(async (resolved) => { + if (!resolved) + return + this.ids.delete(resolved.id) + const urlPaths = resolveMockPaths(resolved.id) + urlPaths.forEach((url) => { + delete this.mocks[url] + delete this.factories[url] + delete this.mockObjects[url] + }) + channel.postMessage({ + type: 'unmock', + paths: urlPaths, + }) + await waitForChannel('unmock:done') + }) + .finally(() => { this.queue.delete(promise) }) this.queue.add(promise) @@ -106,7 +170,9 @@ export class VitestBrowserClientMocker { public async prepare() { if (!this.queue.size) return - await Promise.all([...this.queue.values()]) + await Promise.all([ + ...this.queue.values(), + ]) } // TODO: move this logic into a util(?) @@ -284,3 +350,26 @@ function collectOwnProperties(obj: any, collector: Set | ((key: Object.getOwnPropertyNames(obj).forEach(collect) Object.getOwnPropertySymbols(obj).forEach(collect) } + +function resolvedMockedPath(path: string) { + const config = getBrowserState().viteConfig + if (path.startsWith(config.root)) + return path.slice(config.root.length) + return path +} + +// TODO: check _base_ path +function resolveMockPaths(path: string) { + const config = getBrowserState().viteConfig + const fsRoot = join('/@fs/', config.root) + const paths = [path, join('/@fs/', path)] + + // URL can be /file/path.js, but path is resolved to /file/path + if (path.startsWith(config.root)) + paths.push(path.slice(config.root.length)) + + if (path.startsWith(fsRoot)) + paths.push(path.slice(fsRoot.length)) + + return paths +} diff --git a/packages/browser/src/client/msw.ts b/packages/browser/src/client/msw.ts new file mode 100644 index 000000000000..e170a67f0ae7 --- /dev/null +++ b/packages/browser/src/client/msw.ts @@ -0,0 +1,118 @@ +import { http } from 'msw/core/http' +import { setupWorker } from 'msw/browser' +import type { IframeChannelEvent, IframeMockEvent, IframeMockingDoneEvent, IframeUnmockEvent } from './channel' +import { channel } from './channel' +import { client } from './client' + +export function createModuleMocker() { + const mocks: Map = new Map() + + const worker = setupWorker( + http.get(/.+/, async ({ request }) => { + const path = removeTimestamp(request.url.slice(location.origin.length)) + if (!mocks.has(path)) + return passthrough() + + const mock = mocks.get(path) + + // using a factory + if (mock === undefined) { + // TODO: check how the error looks + const exports = await getFactoryExports(path) + const module = `const module = __vitest_mocker__.get('${path}');` + const keys = exports.map((name) => { + if (name === 'default') + return `export default module['default'];` + return `export const ${name} = module['${name}'];` + }).join('\n') + const text = `${module}\n${keys}` + return new Response(text, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + } + + if (typeof mock === 'string') + return Response.redirect(mock) + + const content = await client.rpc.automock(path) + return new Response(content, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + }), + ) + + let started = false + let startPromise: undefined | Promise + + async function init() { + if (started) + return + if (startPromise) + return startPromise + startPromise = worker.start({ + serviceWorker: { + url: '/__virtual_vitest__:mocker-worker.js', + }, + quiet: true, + }).finally(() => { + started = true + startPromise = undefined + }) + await startPromise + } + + return { + async mock(event: IframeMockEvent) { + await init() + event.paths.forEach(path => mocks.set(path, event.mock)) + channel.postMessage({ type: 'mock:done' }) + }, + async unmock(event: IframeUnmockEvent) { + await init() + event.paths.forEach(path => mocks.delete(path)) + channel.postMessage({ type: 'unmock:done' }) + }, + invalidate() { + mocks.clear() + }, + } +} + +function getFactoryExports(id: string) { + channel.postMessage({ + type: 'mock-factory:request', + id, + }) + return new Promise((resolve, reject) => { + channel.addEventListener('message', function onMessage(e: MessageEvent) { + if (e.data.type === 'mock-factory:response') { + resolve(e.data.exports) + channel.removeEventListener('message', onMessage) + } + if (e.data.type === 'mock-factory:error') { + reject(e.data.error) + channel.removeEventListener('message', onMessage) + } + }) + }) +} + +const timestampRegexp = /(\?|&)t=\d{13}/ + +function removeTimestamp(url: string) { + return url.replace(timestampRegexp, '') +} + +function passthrough() { + return new Response(null, { + status: 302, + statusText: 'Passthrough', + headers: { + 'x-msw-intention': 'passthrough', + }, + }) +} diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index eb3963db550f..4e82157f0537 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -5,6 +5,8 @@ import { channel, client } from './client' import { rpcDone } from './rpc' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' +import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' +import { createModuleMocker } from './msw' const url = new URL(location.href) @@ -35,7 +37,7 @@ function createIframe(container: HTMLDivElement, file: string) { const iframe = document.createElement('iframe') iframe.setAttribute('loading', 'eager') - iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`) + iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${getBrowserState().contextId}/${encodeURIComponent(file)}`) iframe.setAttribute('data-vitest', 'true') iframe.style.display = 'block' @@ -52,36 +54,9 @@ function createIframe(container: HTMLDivElement, file: string) { async function done() { await rpcDone() - await client.rpc.finishBrowserTests() + await client.rpc.finishBrowserTests(getBrowserState().contextId) } -interface IframeDoneEvent { - type: 'done' - filenames: string[] - id: string -} - -interface IframeErrorEvent { - type: 'error' - error: any - errorType: string - files: string[] - id: string -} - -interface IframeViewportEvent { - type: 'viewport' - width: number - height: number - id: string -} - -interface IframeViewportChannelEvent { - type: 'viewport:done' | 'viewport:fail' -} - -type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent | IframeViewportEvent | IframeViewportChannelEvent - async function getContainer(config: ResolvedConfig): Promise { if (config.browser.ui) { const element = document.querySelector('#tester-ui') @@ -107,7 +82,9 @@ client.ws.addEventListener('open', async () => { runningFiles.clear() testFiles.forEach(file => runningFiles.add(file)) - channel.addEventListener('message', async (e: MessageEvent): Promise => { + const mocker = createModuleMocker() + + channel.addEventListener('message', async (e: MessageEvent): Promise => { debug('channel event', JSON.stringify(e.data)) switch (e.data.type) { case 'viewport': { @@ -161,7 +138,22 @@ client.ws.addEventListener('open', async () => { await done() break } + case 'mock:invalidate': + mocker.invalidate() + break + case 'unmock': + await mocker.unmock(e.data) + break + case 'mock': + await mocker.mock(e.data) + break + case 'mock-factory:error': + case 'mock-factory:response': + // handled manually + break default: { + e.data satisfies never + await client.rpc.onUnhandledError({ name: 'Unexpected Event', message: `Unexpected event: ${(e.data as any).type}`, diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index d8020791ce01..a1365fc91d87 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -19,8 +19,10 @@ window.__vitest_browser_runner__ = { wrapModule, moduleCache, config: { __VITEST_CONFIG__ }, + viteConfig: { __VITEST_VITE_CONFIG__ }, files: { __VITEST_FILES__ }, type: { __VITEST_TYPE__ }, + contextId: { __VITEST_CONTEXT_ID__ }, } const config = __vitest_browser_runner__.config diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index 0b3f5fde90a2..3b64befd4818 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -4,6 +4,7 @@ import type { VitestExecutor } from 'vitest/execute' import { rpc } from './rpc' import { importId } from './utils' import { VitestBrowserSnapshotEnvironment } from './snapshot' +import type { VitestBrowserClientMocker } from './mocker' interface BrowserRunnerOptions { config: ResolvedConfig @@ -17,6 +18,7 @@ interface CoverageHandler { export function createBrowserRunner( runnerClass: { new(config: ResolvedConfig): VitestRunner }, + mocker: VitestBrowserClientMocker, state: WorkerGlobalState, coverageModule: CoverageHandler | null, ): { new(options: BrowserRunnerOptions): VitestRunner } { @@ -44,9 +46,11 @@ export function createBrowserRunner( } onAfterRunFiles = async (files: File[]) => { - await rpc().invalidateMocks() - await super.onAfterRunFiles?.(files) - const coverage = await coverageModule?.takeCoverage?.() + const [coverage] = await Promise.all([ + coverageModule?.takeCoverage?.(), + mocker.invalidate(), + super.onAfterRunFiles?.(files), + ]) if (coverage) { await rpc().onAfterSuiteRun({ @@ -85,10 +89,9 @@ export function createBrowserRunner( hash = Date.now().toString() this.hashMap.set(filepath, [false, hash]) } - const base = this.config.base || '/' // on Windows we need the unit to resolve the test file - const prefix = `${base}${/^\w:/.test(filepath) ? '@fs/' : ''}` + const prefix = `/${/^\w:/.test(filepath) ? '@fs/' : ''}` const query = `${test ? 'browserv' : 'v'}=${hash}` const importpath = `${prefix}${filepath}?${query}`.replace(/\/+/g, '/') await import(importpath) @@ -98,7 +101,7 @@ export function createBrowserRunner( let cachedRunner: VitestRunner | null = null -export async function initiateRunner(state: WorkerGlobalState, config: ResolvedConfig) { +export async function initiateRunner(state: WorkerGlobalState, mocker: VitestBrowserClientMocker, config: ResolvedConfig) { if (cachedRunner) return cachedRunner const [ @@ -109,7 +112,7 @@ export async function initiateRunner(state: WorkerGlobalState, config: ResolvedC importId('vitest/browser') as Promise, ]) const runnerClass = config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner - const BrowserRunner = createBrowserRunner(runnerClass, state, { + const BrowserRunner = createBrowserRunner(runnerClass, mocker, state, { takeCoverage: () => takeCoverageInsideWorker(config.coverage, { executeId: importId }), }) if (!config.snapshotOptions.snapshotEnvironment) diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index a2486a4df13d..9cd1c19d6141 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -60,7 +60,7 @@ async function prepareTestEnvironment(files: string[]) { debug('trying to resolve runner', `${reloadStart}`) const config = getConfig() - const viteClientPath = `${config.base || '/'}@vite/client` + const viteClientPath = `/@vite/client` await import(viteClientPath) const rpc: any = await loadSafeRpc(client) @@ -118,12 +118,13 @@ async function prepareTestEnvironment(files: string[]) { browserHashMap.set(filename, [true, version]) }) - const [runner, { startTests, setupCommonEnv, Spy }] = await Promise.all([ - initiateRunner(state, config), + const [runner, { startTests, setupCommonEnv, SpyModule }] = await Promise.all([ + initiateRunner(state, mocker, config), importId('vitest/browser') as Promise, ]) - mocker.setSpyModule(Spy) + mocker.setSpyModule(SpyModule) + mocker.setupWorker() onCancel.then((reason) => { runner.onCancel?.(reason) diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index f8fedcc2f517..54f99d73be1d 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -1,7 +1,7 @@ import type { ResolvedConfig, WorkerGlobalState } from 'vitest' export async function importId(id: string) { - const name = `${getConfig().base || '/'}@id/${id}` + const name = `/@id/${id}` return getBrowserState().wrapModule(() => import(name)) } @@ -9,14 +9,18 @@ export function getConfig(): ResolvedConfig { return getBrowserState().config } -interface BrowserRunnerState { +export interface BrowserRunnerState { files: string[] runningFiles: string[] moduleCache: WorkerGlobalState['moduleCache'] config: ResolvedConfig + viteConfig: { + root: string + } type: 'tester' | 'orchestrator' wrapModule: (module: () => T) => T iframeId?: string + contextId: string runTests?: (tests: string[]) => Promise createTesters?: (files: string[]) => Promise } diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts index 71f17b7dcbd4..dfa8fe446607 100644 --- a/packages/browser/src/client/vite.config.ts +++ b/packages/browser/src/client/vite.config.ts @@ -19,9 +19,18 @@ export default defineConfig({ orchestrator: resolve(__dirname, './orchestrator.html'), tester: resolve(__dirname, './tester.html'), }, + external: [/__virtual_vitest__/], }, }, plugins: [ + { + name: 'virtual:msw', + enforce: 'pre', + resolveId(id) { + if (id.startsWith('msw')) + return `/__virtual_vitest__:${id}` + }, + }, { name: 'copy-ui-plugin', /* eslint-disable no-console */ diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts index cd36bc83bbd2..5d93e34ca715 100644 --- a/packages/browser/src/node/commands/click.ts +++ b/packages/browser/src/node/commands/click.ts @@ -1,23 +1,24 @@ -import type { Page } from 'playwright' import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' import type { UserEventCommand } from './utils' export const click: UserEventCommand = async ( - { provider }, - element, + context, + xpath, options = {}, ) => { - if (provider.name === 'playwright') { - const page = (provider as any).page as Page - await page.frameLocator('iframe[data-vitest]').locator(`xpath=${element}`).click(options) + const provider = context.provider + if (provider instanceof PlaywrightBrowserProvider) { + const tester = context.tester + await tester.locator(`xpath=${xpath}`).click(options) return } - if (provider.name === 'webdriverio') { - const page = (provider as any).browser as WebdriverIO.Browser - const frame = await page.findElement('css selector', 'iframe[data-vitest]') - await page.switchToFrame(frame) - const xpath = `//${element}` - await (await page.$(xpath)).click(options) + if (provider instanceof WebdriverBrowserProvider) { + const page = provider.browser! + const markedXpath = `//${xpath}` + const element = await page.$(markedXpath) + await element.click(options) return } throw new Error(`Provider "${provider.name}" doesn't support click command`) diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 5314fc3fc073..0f6946e20dae 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -5,6 +5,7 @@ import { writeFile, } from './fs' import { sendKeys } from './keyboard' +import { screenshot } from './screenshot' export default { readFile, @@ -12,4 +13,5 @@ export default { writeFile, sendKeys, __vitest_click: click, + __vitest_screenshot: screenshot, } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index 5f9dba3f2a52..6b7959971df2 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -1,6 +1,5 @@ // based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts -import type { Page } from 'playwright' import type { BrowserCommand } from 'vitest/node' import type { BrowserCommands, @@ -10,6 +9,8 @@ import type { TypePayload, UpPayload, } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' function isObject(payload: unknown): payload is Record { return payload != null && typeof payload === 'object' @@ -62,12 +63,12 @@ function isUpPayload(payload: SendKeysPayload): payload is UpPayload { return 'up' in payload } -export const sendKeys: BrowserCommand> = async ({ provider }, payload) => { +export const sendKeys: BrowserCommand> = async ({ provider, contextId }, payload) => { if (!isSendKeysPayload(payload) || !payload) throw new Error('You must provide a `SendKeysPayload` object') - if (provider.name === 'playwright') { - const page = (provider as any).page as Page + if (provider instanceof PlaywrightBrowserProvider) { + const page = provider.getPage(contextId) if (isTypePayload(payload)) await page.keyboard.type(payload.type) else if (isPressPayload(payload)) @@ -77,8 +78,8 @@ export const sendKeys: BrowserCommand> = else if (isUpPayload(payload)) await page.keyboard.up(payload.up) } - else if (provider.name === 'webdriverio') { - const browser = (provider as any).browser as WebdriverIO.Browser + else if (provider instanceof WebdriverBrowserProvider) { + const browser = provider.browser! if (isTypePayload(payload)) await browser.keys(payload.type.split('')) else if (isPressPayload(payload)) @@ -87,6 +88,6 @@ export const sendKeys: BrowserCommand> = throw new Error('Only "press" and "type" are supported by webdriverio.') } else { - throw new Error(`"sendKeys" is not supported for ${provider.name} browser provider.`) + throw new TypeError(`"sendKeys" is not supported for ${provider.name} browser provider.`) } } diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts new file mode 100644 index 000000000000..c9e44fa33506 --- /dev/null +++ b/packages/browser/src/node/commands/screenshot.ts @@ -0,0 +1,60 @@ +import { mkdir } from 'node:fs/promises' +import { normalize } from 'node:path' +import type { BrowserCommand } from 'vitest/node' +import { basename, dirname, relative, resolve } from 'pathe' +import type { ResolvedConfig } from 'vitest' +import type { ScreenshotOptions } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' + +// TODO: expose provider specific options in types +export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (context, name: string, options = {}) => { + if (!context.testPath) + throw new Error(`Cannot take a screenshot without a test path`) + + const path = resolveScreenshotPath(context.testPath, name, context.project.config) + const savePath = normalize(path) + await mkdir(dirname(path), { recursive: true }) + + if (context.provider instanceof PlaywrightBrowserProvider) { + if (options.element) { + const { element: elementXpath, ...config } = options + const iframe = context.tester + const element = iframe.locator(`xpath=${elementXpath}`) + await element.screenshot({ ...config, path: savePath }) + } + else { + await context.body.screenshot({ ...options, path: savePath }) + } + return path + } + + if (context.provider instanceof WebdriverBrowserProvider) { + const page = context.provider.browser! + if (!options.element) { + const body = await page.$('body') + await body.saveScreenshot(savePath) + return path + } + const xpath = `//${options.element}` + const element = await page.$(xpath) + await element.saveScreenshot(savePath) + return path + } + + throw new Error(`Provider "${context.provider.name}" does not support screenshots`) +} + +function resolveScreenshotPath(testPath: string, name: string, config: ResolvedConfig) { + 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) +} diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 3d4013b38037..6e4db8456872 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -8,3 +8,9 @@ type ConvertElementToLocator = T extends Element ? string : T type ConvertUserEventParameters = { [K in keyof T]: ConvertElementToLocator } + +export function defineBrowserCommand( + fn: BrowserCommand, +): BrowserCommand { + return fn +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index a05e3392c7b0..78b4e9f6af6b 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -10,17 +10,16 @@ import { getFilePoolName, distDir as vitestDist } from 'vitest/node' import { type Plugin, coverageConfigDefaults } from 'vitest/config' import { slash, toArray } from '@vitest/utils' import BrowserContext from './plugins/pluginContext' -import BrowserMocker from './plugins/pluginMocker' import DynamicImport from './plugins/pluginDynamicImport' export type { BrowserCommand } from 'vitest/node' +export { defineBrowserCommand } from './commands/utils' export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') return [ - ...BrowserMocker(project), { enforce: 'pre', name: 'vitest:browser', @@ -62,22 +61,32 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate') res.setHeader('Content-Type', 'text/html; charset=utf-8') - const files = project.browserState?.files ?? [] - const config = wrapConfig(project.getSerializableConfig()) config.env ??= {} config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' - const injector = replacer(await injectorJs, { - __VITEST_CONFIG__: JSON.stringify(config), - __VITEST_FILES__: JSON.stringify(files), - __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', - }) - // remove custom iframe related headers to allow the iframe to load res.removeHeader('X-Frame-Options') if (url.pathname === base) { + let contextId = url.searchParams.get('contextId') + // it's possible to open the page without a context, + // for now, let's assume it should be the first one + if (!contextId) + contextId = project.browserState.keys().next().value ?? 'none' + + const files = project.browserState.get(contextId!)?.files ?? [] + + const injector = replacer(await injectorJs, { + __VITEST_CONFIG__: JSON.stringify(config), + __VITEST_VITE_CONFIG__: JSON.stringify({ + root: project.browser!.config.root, + }), + __VITEST_FILES__: JSON.stringify(files), + __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + }) + // disable CSP for the orchestrator as we are the ones controlling it res.removeHeader('Content-Security-Policy') @@ -105,6 +114,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { __VITEST_TITLE__: 'Vitest Browser Runner', __VITEST_SCRIPTS__: orchestratorScripts, __VITEST_INJECTOR__: injector, + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), }) res.write(html, 'utf-8') res.end() @@ -118,11 +128,23 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { res.setHeader('Content-Security-Policy', csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *')) } - const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length)) + const [contextId, testFile] = url.pathname.slice(testerPrefix.length).split('/') + const decodedTestFile = decodeURIComponent(testFile) const testFiles = await project.globTestFiles() // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests const tests = decodedTestFile === '__vitest_all__' || !testFiles.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile]) const iframeId = JSON.stringify(decodedTestFile) + const files = project.browserState.get(contextId)?.files ?? [] + + const injector = replacer(await injectorJs, { + __VITEST_CONFIG__: JSON.stringify(config), + __VITEST_FILES__: JSON.stringify(files), + __VITEST_VITE_CONFIG__: JSON.stringify({ + root: project.browser!.config.root, + }), + __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + }) if (!testerScripts) testerScripts = await formatScripts(project.config.browser.testerScripts, server) @@ -200,6 +222,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { 'tinybench', 'tinyspy', 'pathe', + 'msw', + 'msw/browser', ], include: [ 'vitest > @vitest/utils > pretty-format', @@ -232,6 +256,26 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { return useId }, }, + { + name: 'vitest:browser:resolve-virtual', + async resolveId(rawId) { + if (rawId.startsWith('/__virtual_vitest__:')) { + let id = rawId.slice('/__virtual_vitest__:'.length) + // TODO: don't hardcode + if (id === 'mocker-worker.js') + id = 'msw/mockServiceWorker.js' + + const resolved = await this.resolve( + id, + distRoot, + { + skipSelf: true, + }, + ) + return resolved + } + }, + }, BrowserContext(project), DynamicImport(), // TODO: remove this when @testing-library/vue supports ESM diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 4a13535fcd14..146164999c1f 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url' import type { Plugin } from 'vitest/config' import type { BrowserProvider, WorkspaceProject } from 'vitest/node' -import { dirname } from 'pathe' +import { dirname, resolve } from 'pathe' import type { PluginContext } from 'rollup' import { slash } from '@vitest/utils' import builtinCommands from '../commands/index' @@ -41,17 +41,19 @@ async function generateContextFile(this: PluginContext, project: WorkspaceProjec const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined' const provider = project.browserProvider! - const commandsCode = commands.map((command) => { - return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", filepath(), args),` + const commandsCode = commands.filter(command => !command.startsWith('__vitest')).map((command) => { + return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),` }).join('\n') const userEventNonProviderImport = await getUserEventImport(provider, this.resolve.bind(this)) + const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) return ` +import { page, userEvent as __userEvent_CDP__ } from '${distContextPath}' ${userEventNonProviderImport} const filepath = () => ${filepathCode} const rpc = () => __vitest_worker__.rpc -const channel = new BroadcastChannel('vitest') +const contextId = __vitest_browser_runner__.contextId export const server = { platform: ${JSON.stringify(process.platform)}, @@ -63,55 +65,8 @@ export const server = { } } export const commands = server.commands -export const page = { - get config() { - return __vitest_browser_runner__.config - }, - viewport(width, height) { - const id = __vitest_browser_runner__.iframeId - channel.postMessage({ type: 'viewport', width, height, id }) - return new Promise((resolve, reject) => { - channel.addEventListener('message', function handler(e) { - if (e.data.type === 'viewport:done' && e.data.id === id) { - channel.removeEventListener('message', handler) - resolve() - } - if (e.data.type === 'viewport:fail' && e.data.id === id) { - channel.removeEventListener('message', handler) - reject(new Error(e.data.error)) - } - }) - }) - }, -} - -export const userEvent = ${getUserEventScript(project)} - -function convertElementToXPath(element) { - if (!element || !(element instanceof Element)) { - // TODO: better error message - throw new Error('Expected element to be an instance of Element') - } - return getPathTo(element) -} - -function getPathTo(element) { - if (element.id !== '') - return \`id("\${element.id}")\` - - if (!element.parentNode || element === document.documentElement) - return element.tagName - - let ix = 0 - const siblings = element.parentNode.childNodes - for (let i = 0; i < siblings.length; i++) { - const sibling = siblings[i] - if (sibling === element) - return \`\${getPathTo(element.parentNode)}/\${element.tagName}[\${ix + 1}]\` - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) - ix++ - } -} +export const userEvent = ${provider.name === 'preview' ? '__vitest_user_event__' : '__userEvent_CDP__'} +export { page } ` } @@ -123,14 +78,3 @@ async function getUserEventImport(provider: BrowserProvider, resolve: (id: strin throw new Error(`Failed to resolve user-event package from ${__dirname}`) return `import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}'` } - -function getUserEventScript(project: WorkspaceProject) { - if (project.browserProvider?.name === 'preview') - return `__vitest_user_event__` - return `{ - async click(element, options) { - const xpath = convertElementToXPath(element) - return rpc().triggerCommand('__vitest_click', filepath(), options ? [xpath, options] : [xpath]); - }, -}` -} diff --git a/packages/browser/src/node/plugins/pluginMocker.ts b/packages/browser/src/node/plugins/pluginMocker.ts deleted file mode 100644 index 1b2d269a6b3a..000000000000 --- a/packages/browser/src/node/plugins/pluginMocker.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readFile } from 'node:fs/promises' -import type { Plugin } from 'vitest/config' -import type { WorkspaceProject } from 'vitest/node' -import { automockModule } from '../automocker' - -export default (project: WorkspaceProject): Plugin[] => { - return [ - { - name: 'vitest:browser:mocker', - enforce: 'pre', - async load(id) { - const data = project.browserMocker.mocks.get(id) - if (!data) - return - const { mock, sessionId } = data - // undefined mock means there is a factory in the browser - if (mock === undefined) { - const rpc = project.browserRpc.testers.get(sessionId) - - if (!rpc) - throw new Error(`WebSocket rpc was destroyed for session ${sessionId}`) - - const exports = await rpc.startMocking(id) - const module = `const module = __vitest_mocker__.get('${id}');` - const keys = exports.map((name) => { - if (name === 'default') - return `export default module['default'];` - return `export const ${name} = module['${name}'];` - }).join('\n') - return `${module}\n${keys}` - } - - // should import the same module and automock all exports - if (mock === null) - return - - // file is inside __mocks__ - return readFile(mock, 'utf-8') - }, - }, - { - name: 'vitest:browser:automocker', - enforce: 'post', - transform(code, id) { - const data = project.browserMocker.mocks.get(id) - if (!data) - return - if (data.mock === null) { - const m = automockModule(code, this.parse) - - return { - code: m.toString(), - map: m.generateMap({ hires: 'boundary', source: id }), - } - } - }, - }, - ] -} diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index d7c19d724cb9..cfb7b5261303 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -1,4 +1,4 @@ -import type { Browser, LaunchOptions, Page } from 'playwright' +import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions, Page } from 'playwright' import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node' export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const @@ -9,19 +9,24 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization } export class PlaywrightBrowserProvider implements BrowserProvider { - public name = 'playwright' + public name = 'playwright' as const + public supportsParallelism = true public browser: Browser | null = null - public page: Page | null = null private browserName!: PlaywrightBrowser private ctx!: WorkspaceProject private options?: { launch?: LaunchOptions - page?: Parameters[0] + context?: BrowserContextOptions } + public contexts = new Map() + public pages = new Map() + + private browserPromise: Promise | null = null + getSupportedBrowsers() { return playwrightBrowsers } @@ -32,35 +37,89 @@ export class PlaywrightBrowserProvider implements BrowserProvider { this.options = options as any } - private async openBrowserPage() { - if (this.page) - return this.page + private async openBrowser() { + if (this.browserPromise) + return this.browserPromise + + if (this.browser) + return this.browser + + this.browserPromise = (async () => { + const options = this.ctx.config.browser - const options = this.ctx.config.browser + const playwright = await import('playwright') + + const browser = await playwright[this.browserName].launch({ + ...this.options?.launch, + headless: options.headless, + }) + this.browser = browser + this.browserPromise = null + return this.browser + })() + + return this.browserPromise + } - const playwright = await import('playwright') + private async createContext(contextId: string) { + if (this.contexts.has(contextId)) + return this.contexts.get(contextId)! - const browser = await playwright[this.browserName].launch({ - ...this.options?.launch, - headless: options.headless, + const browser = await this.openBrowser() + const context = await browser.newContext({ + ...this.options?.context, + ignoreHTTPSErrors: true, + serviceWorkers: 'allow', }) - this.browser = browser - this.page = await browser.newPage(this.options?.page) + this.contexts.set(contextId, context) + return context + } + + public getPage(contextId: string) { + const page = this.pages.get(contextId) + if (!page) + throw new Error(`Page "${contextId}" not found`) + return page + } + + public getCommandsContext(contextId: string) { + const page = this.getPage(contextId) + const tester = page.frameLocator('iframe[data-vitest]') + return { + page, + tester, + get body() { + return page.frameLocator('iframe[data-vitest]').locator('body') + }, + } + } + + private async openBrowserPage(contextId: string) { + if (this.pages.has(contextId)) { + const page = this.pages.get(contextId)! + await page.close() + this.pages.delete(contextId) + } + + const context = await this.createContext(contextId) + const page = await context.newPage() + this.pages.set(contextId, page) - return this.page + return page } - async openPage(url: string) { - const browserPage = await this.openBrowserPage() + async openPage(contextId: string, url: string) { + const browserPage = await this.openBrowserPage(contextId) await browserPage.goto(url) } async close() { - const page = this.page - this.page = null const browser = this.browser this.browser = null - await page?.close() + 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() } } diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index 4ed5c939656b..45bfde31576a 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -1,7 +1,8 @@ import type { BrowserProvider, WorkspaceProject } from 'vitest/node' export class PreviewBrowserProvider implements BrowserProvider { - public name = 'preview' + public name = 'preview' as const + public supportsParallelism: boolean = false private ctx!: WorkspaceProject private open = false @@ -14,6 +15,10 @@ export class PreviewBrowserProvider implements BrowserProvider { return this.open } + getCommandsContext() { + return {} + } + async initialize(ctx: WorkspaceProject) { this.ctx = ctx this.open = false @@ -21,13 +26,13 @@ export class PreviewBrowserProvider implements BrowserProvider { 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') } - async openPage(_url: string) { + async openPage(_contextId: string, url: string) { this.open = true if (!this.ctx.browser) throw new Error('Browser is not initialized') const options = this.ctx.browser.config.server const _open = options.open - options.open = _url + options.open = url this.ctx.browser.openBrowser() options.open = _open } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 3a768430e203..e7a90fe53616 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -9,7 +9,8 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions } export class WebdriverBrowserProvider implements BrowserProvider { - public name = 'webdriverio' + public name = 'webdriverio' as const + public supportsParallelism: boolean = false public browser: WebdriverIO.Browser | null = null @@ -28,6 +29,22 @@ export class WebdriverBrowserProvider implements BrowserProvider { this.options = options as RemoteOptions } + async beforeCommand() { + const page = this.browser! + const iframe = await page.findElement('css selector', 'iframe[data-vitest]') + await page.switchToFrame(iframe) + } + + async afterCommand() { + await this.browser!.switchToParentFrame() + } + + getCommandsContext() { + return { + browser: this.browser, + } + } + async openBrowser() { if (this.browser) return this.browser @@ -75,7 +92,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { return capabilities } - async openPage(url: string) { + async openPage(_contextId: string, url: string) { const browserInstance = await this.openBrowser() await browserInstance.url(url) } diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index e6e667fb253e..484bc0379c82 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -34,6 +34,7 @@ const stackIgnorePatterns = [ /node:\w+/, /__vitest_test__/, /__vitest_browser__/, + /\/deps\/vitest_/, ] function extractLocation(urlLike: string) { @@ -46,6 +47,8 @@ function extractLocation(urlLike: string) { if (!parts) return [urlLike] let url = parts[1] + if (url.startsWith('async ')) + url = url.slice(6) if (url.startsWith('http:') || url.startsWith('https:')) { const urlObj = new URL(url) url = urlObj.pathname diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index 00d5225a173b..e012ba88698a 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -312,7 +312,7 @@ Repository: micromatch/braces > The MIT License (MIT) > -> Copyright (c) 2014-2018, Jon Schlinkert. +> Copyright (c) 2014-present, Jon Schlinkert. > > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal diff --git a/packages/vitest/package.json b/packages/vitest/package.json index eed78da42bdc..c1707c5196c8 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -147,6 +147,7 @@ } }, "dependencies": { + "@ampproject/remapping": "^2.3.0", "@vitest/expect": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/snapshot": "workspace:*", @@ -166,10 +167,10 @@ "why-is-node-running": "^2.2.2" }, "devDependencies": { - "@ampproject/remapping": "^2.3.0", "@antfu/install-pkg": "0.3.1", "@edge-runtime/vm": "^3.2.0", "@sinonjs/fake-timers": "11.1.0", + "@types/debug": "^4.1.12", "@types/estree": "^1.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 6967c7349238..bd93edc85ce6 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -5,13 +5,20 @@ import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' import type { WebSocket } from 'ws' import { WebSocketServer } from 'ws' -import { isFileServingAllowed } from 'vite' +import { isFileServingAllowed, parseAst } from 'vite' import type { ViteDevServer } from 'vite' +import type { EncodedSourceMap } from '@ampproject/remapping' +import remapping from '@ampproject/remapping' import { BROWSER_API_PATH } from '../constants' import { stringifyReplace } from '../utils' import type { WorkspaceProject } from '../node/workspace' +import { createDebugger } from '../utils/debugger' +import { automockModule } from '../node/automockBrowser' +import type { BrowserCommandContext } from '../types/browser' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' +const debug = createDebugger('vitest:browser:api') + export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer) { const ctx = project.ctx @@ -36,7 +43,10 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer const clients = type === 'tester' ? rpcs.testers : rpcs.orchestrators clients.set(sessionId, rpc) + debug?.('[%s] Browser API connected to %s', sessionId, type) + ws.on('close', () => { + debug?.('[%s] Browser API disconnected from %s', sessionId, type) clients.delete(sessionId) }) }) @@ -112,42 +122,65 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, - triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) { - if (!project.browserProvider) + async triggerCommand(contextId, command, testPath, payload) { + debug?.('[%s] Triggering command "%s"', contextId, command) + const provider = project.browserProvider + if (!provider) throw new Error('Commands are only available for browser tests.') const commands = project.config.browser?.commands if (!commands || !commands[command]) throw new Error(`Unknown command "${command}".`) - return commands[command]({ + if (provider.beforeCommand) + await provider.beforeCommand(command, payload) + const context = Object.assign({ testPath, project, - provider: project.browserProvider, - }, ...payload) - }, - getBrowserFiles() { - return project.browserState?.files ?? [] + provider, + contextId, + }, provider.getCommandsContext(contextId)) as any as BrowserCommandContext + let result + try { + result = await commands[command](context, ...payload) + } + finally { + if (provider.afterCommand) + await provider.afterCommand(command, payload) + } + return result }, - finishBrowserTests() { - return project.browserState?.resolve() + finishBrowserTests(contextId: string) { + debug?.('[%s] Finishing browser tests for context', contextId) + return project.browserState.get(contextId)?.resolve() }, getProvidedContext() { return 'ctx' in project ? project.getProvidedContext() : ({} as any) }, - async queueMock(id: string, importer: string, hasFactory: boolean) { - return project.browserMocker.mock(sessionId, id, importer, hasFactory) - }, - async queueUnmock(id: string, importer: string) { - return project.browserMocker.unmock(id, importer) - }, - resolveMock(rawId: string, importer: string) { - return project.browserMocker.resolveMock(rawId, importer, false) - }, - invalidateMocks() { - const mocker = project.browserMocker - mocker.mocks.forEach((_, id) => { - mocker.invalidateModuleById(id) + // TODO: cache this automock result + async automock(id) { + const result = await project.browser!.transformRequest(id) + if (!result) + throw new Error(`Module "${id}" not found.`) + const ms = automockModule(result.code, parseAst) + const code = ms.toString() + const sourcemap = ms.generateMap({ hires: 'boundary', source: id }) + const combinedMap = result.map && result.map.mappings + ? remapping( + [{ ...sourcemap, version: 3 }, result.map as EncodedSourceMap], + () => null, + ) + : sourcemap + return `${code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(combinedMap)).toString('base64')}` + }, + resolveMock(rawId, importer, hasFactory) { + return project.browserMocker.resolveMock(rawId, importer, hasFactory) + }, + invalidate(ids) { + ids.forEach((id) => { + const moduleGraph = project.browser!.moduleGraph + const module = moduleGraph.getModuleById(id) + if (module) + moduleGraph.invalidateModule(module, new Set(), Date.now(), true) }) - mocker.mocks.clear() }, }, { diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index e3ec7a90357e..c92ebf77f9db 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -81,7 +81,7 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) { return result } }, - async getModuleGraph(project: string, id: string, browser?: boolean): Promise { + async getModuleGraph(project, id, browser): Promise { return getModuleGraph(ctx, project, id, browser) }, updateSnapshot(file?: File) { diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index d32985f9799d..d17cd7594aaf 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -37,20 +37,18 @@ export interface WebSocketBrowserHandlers { saveSnapshotFile: (id: string, content: string) => Promise removeSnapshotFile: (id: string) => Promise sendLog: (log: UserConsoleLog) => void - finishBrowserTests: () => void + finishBrowserTests: (contextId: string) => void snapshotSaved: (snapshot: SnapshotResult) => void - getBrowserFiles: () => string[] debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise - triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise - queueMock: (id: string, importer: string, hasFactory: boolean) => Promise - queueUnmock: (id: string, importer: string) => Promise - resolveMock: (id: string, importer: string) => Promise<{ + triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise + resolveMock: (id: string, importer: string, hasFactory: boolean) => Promise<{ type: 'factory' | 'redirect' | 'automock' mockPath?: string | null resolvedId: string }> - invalidateMocks: () => void + automock: (id: string) => Promise + invalidate: (ids: string[]) => void getBrowserFileSourceMap: (id: string) => Promise getProvidedContext: () => ProvidedContext } diff --git a/packages/vitest/src/browser.ts b/packages/vitest/src/browser.ts index 5515f458c441..e9eb3ea0cb3c 100644 --- a/packages/vitest/src/browser.ts +++ b/packages/vitest/src/browser.ts @@ -10,4 +10,4 @@ export { getCoverageProvider, startCoverageInsideWorker, } from './integrations/coverage' -export * as Spy from './integrations/spy' +export * as SpyModule from './integrations/spy' diff --git a/packages/vitest/src/integrations/browser/mocker.ts b/packages/vitest/src/integrations/browser/mocker.ts index 23d2da272b4c..3639d7176392 100644 --- a/packages/vitest/src/integrations/browser/mocker.ts +++ b/packages/vitest/src/integrations/browser/mocker.ts @@ -17,29 +17,6 @@ export class VitestBrowserServerMocker { this.#project = project } - async mock(sessionId: string, rawId: string, importer: string, hasFactory: boolean) { - const { type, mockPath, resolvedId } = await this.resolveMock(rawId, importer, hasFactory) - - this.invalidateModuleById(resolvedId) - - if (type === 'factory') { - this.mocks.set(resolvedId, { sessionId, mock: undefined }) - return resolvedId - } - - this.mocks.set(resolvedId, { sessionId, mock: mockPath }) - - return resolvedId - } - - async unmock(rawId: string, importer: string) { - const { id } = await this.resolveId(rawId, importer) - - this.invalidateModuleById(id) - this.mocks.delete(id) - return id - } - public async resolveMock(rawId: string, importer: string, hasFactory: boolean) { const { id, fsPath, external } = await this.resolveId(rawId, importer) @@ -55,13 +32,6 @@ export class VitestBrowserServerMocker { } } - public invalidateModuleById(id: string) { - const moduleGraph = this.#project.browser!.moduleGraph - const module = moduleGraph.getModuleById(id) - if (module) - moduleGraph.invalidateModule(module, new Set(), Date.now(), true) - } - private async resolveId(rawId: string, importer: string) { const resolved = await this.#project.browser!.pluginContainer.resolveId(rawId, importer, { ssr: false, diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index bc93ce8433e1..75554ff5b5e2 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -17,6 +17,7 @@ export async function createBrowserServer(project: WorkspaceProject, configFile: const server = await createServer({ ...project.options, // spread project config inlined in root workspace config + base: '/', logLevel: 'error', mode: project.config.mode, configFile: configPath, diff --git a/packages/vitest/src/node/automockBrowser.ts b/packages/vitest/src/node/automockBrowser.ts new file mode 100644 index 000000000000..fc6bb550d4b1 --- /dev/null +++ b/packages/vitest/src/node/automockBrowser.ts @@ -0,0 +1,148 @@ +import type { Declaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Identifier, Literal, Pattern, Positioned, Program } from '@vitest/utils/ast' +import MagicString from 'magic-string' + +// TODO: better source map replacement +export function automockModule(code: string, parse: (code: string) => Program) { + const ast = parse(code) + + const m = new MagicString(code) + + const allSpecifiers: { name: string; alias?: string }[] = [] + let importIndex = 0 + for (const _node of ast.body) { + if (_node.type === 'ExportAllDeclaration') { + throw new Error( + `automocking files with \`export *\` is not supported in browser mode because it cannot be statically analysed`, + ) + } + + if (_node.type === 'ExportNamedDeclaration') { + const node = _node as Positioned + const declaration = node.declaration // export const name + + function traversePattern(expression: Pattern) { + // export const test = '1' + if (expression.type === 'Identifier') { + allSpecifiers.push({ name: expression.name }) + } + // export const [test, ...rest] = [1, 2, 3] + else if (expression.type === 'ArrayPattern') { + expression.elements.forEach((element) => { + if (!element) + return + traversePattern(element) + }) + } + else if (expression.type === 'ObjectPattern') { + expression.properties.forEach((property) => { + // export const { ...rest } = {} + if (property.type === 'RestElement') + traversePattern(property) + // export const { test, test2: alias } = {} + else if (property.type === 'Property') + traversePattern(property.value) + else + property satisfies never + }) + } + else if (expression.type === 'RestElement') { + traversePattern(expression.argument) + } + // const [name[1], name[2]] = [] + // cannot be used in export + else if (expression.type === 'AssignmentPattern') { + throw new Error(`AssignmentPattern is not supported. Please open a new bug report.`) + } + // const test = thing.func() + // cannot be used in export + else if (expression.type === 'MemberExpression') { + throw new Error(`MemberExpression is not supported. Please open a new bug report.`) + } + else { + expression satisfies never + } + } + + if (declaration) { + if (declaration.type === 'FunctionDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else if (declaration.type === 'VariableDeclaration') { + declaration.declarations.forEach((declaration) => { + traversePattern(declaration.id) + }) + } + else if (declaration.type === 'ClassDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else { + declaration satisfies never + } + m.remove(node.start, (declaration as Positioned).start) + } + + const specifiers = node.specifiers || [] + const source = node.source + + if (!source && specifiers.length) { + specifiers.forEach((specifier) => { + const exported = specifier.exported as Literal | Identifier + + allSpecifiers.push({ + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + name: specifier.local.name, + }) + }) + m.remove(node.start, node.end) + } + else if (source && specifiers.length) { + const importNames: [string, string][] = [] + + specifiers.forEach((specifier) => { + const importedName = `__vitest_imported_${importIndex++}__` + const exported = specifier.exported as Literal | Identifier + importNames.push([specifier.local.name, importedName]) + allSpecifiers.push({ + name: importedName, + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + }) + }) + + const importString = `import { ${importNames.map(([name, alias]) => `${name} as ${alias}`).join(', ')} } from '${source.value}'` + + m.overwrite(node.start, node.end, importString) + } + } + if (_node.type === 'ExportDefaultDeclaration') { + const node = _node as Positioned + const declaration = node.declaration as Positioned + allSpecifiers.push({ name: '__vitest_default', alias: 'default' }) + m.overwrite(node.start, declaration.start, `const __vitest_default = `) + } + } + const moduleObject = ` +const __vitest_es_current_module__ = { + __esModule: true, + ${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')} +} +const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) +` + const assigning = allSpecifiers.map(({ name }, index) => { + return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]` + }).join('\n') + + const redeclarations = allSpecifiers.map(({ name, alias }, index) => { + return ` __vitest_mocked_${index}__ as ${alias || name},` + }).join('\n') + const specifiersExports = ` +export { +${redeclarations} +} +` + m.append(moduleObject + assigning + specifiersExports) + return m +} diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index f858ea1ecab3..9e51d02dd31b 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -347,10 +347,14 @@ export const cliOptionsConfig: VitestCLIOptions = { ui: { description: 'Show Vitest UI when running tests (default: `!process.env.CI`)', }, + fileParallelism: { + description: 'Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`)', + }, orchestratorScripts: null, testerScripts: null, commands: null, viewport: null, + screenshotDirectory: null, }, }, pool: { diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 90e49c7249fd..f9ab1db2d7dd 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -536,8 +536,11 @@ export function resolveConfig( resolved.browser.enabled ??= false resolved.browser.headless ??= isCI resolved.browser.isolate ??= true + resolved.browser.fileParallelism ??= options.fileParallelism ?? mode !== 'benchmark' // disable in headless mode by default, and if CI is detected - resolved.browser.ui ??= resolved.browser.headless === false ? true : !isCI + resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI + if (resolved.browser.screenshotDirectory) + resolved.browser.screenshotDirectory = resolve(resolved.root, resolved.browser.screenshotDirectory) resolved.browser.viewport ??= {} as any resolved.browser.viewport.width ??= 414 diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 752c0020e51a..b27b8dd8c06e 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -22,6 +22,7 @@ export type { BrowserProviderOptions, BrowserScript, BrowserCommand, + BrowserCommandContext, } from '../types/browser' export type { JsonOptions } from './reporters/json' export type { JUnitOptions } from './reporters/junit' diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 3d0e2b2c1977..7daa5ad64ed3 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -1,34 +1,40 @@ +import * as nodeos from 'node:os' +import crypto from 'node:crypto' import { createDefer } from '@vitest/utils' +import { relative } from 'pathe' import type { Vitest } from '../core' import type { ProcessPool } from '../pool' import type { WorkspaceProject } from '../workspace' import type { BrowserProvider } from '../../types/browser' +import { createDebugger } from '../../utils/debugger' + +const debug = createDebugger('vitest:browser:pool') export function createBrowserPool(ctx: Vitest): ProcessPool { const providers = new Set() - const waitForTests = async (project: WorkspaceProject, files: string[]) => { + const waitForTests = async (contextId: string, project: WorkspaceProject, files: string[]) => { const defer = createDefer() - project.browserState?.resolve() - project.browserState = { + project.browserState.set(contextId, { files, resolve: () => { defer.resolve() - project.browserState = undefined + project.browserState.delete(contextId) }, reject: defer.reject, - } + }) return await defer } const runTests = async (project: WorkspaceProject, files: string[]) => { ctx.state.clearFiles(project, files) - const mocker = project.browserMocker - mocker.mocks.forEach((_, id) => { - mocker.invalidateModuleById(id) - }) - mocker.mocks.clear() + // const mocker = project.browserMocker + // mocker.mocks.forEach((_, id) => { + // mocker.invalidateModuleById(id) + // }) + // mocker.mocks.clear() + const threadsCount = getThreadsCount(project) // TODO // let isCancelled = false // project.ctx.onCancel(() => { @@ -44,19 +50,57 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { if (!origin) throw new Error(`Can't find browser origin URL for project "${project.config.name}"`) - const promise = waitForTests(project, files) + const filesPerThread = Math.ceil(files.length / threadsCount) - const orchestrators = project.browserRpc.orchestrators - if (orchestrators.size) { - orchestrators.forEach((orchestrator) => { - orchestrator.createTesters(files) - }) - } - else { - await provider.openPage(new URL('/', origin).toString()) + // TODO: make it smarter, + // Currently if we run 4/4/4/4 tests, and one of the chunks ends, + // but there are pending tests in another chunks, we can't redistribute them + const chunks: string[][] = [] + for (let i = 0; i < files.length; i += filesPerThread) { + const chunk = files.slice(i, i + filesPerThread) + chunks.push(chunk) } - await promise + debug?.( + `[%s] Running %s tests in %s chunks (%s threads)`, + project.getName() || 'core', + files.length, + chunks.length, + threadsCount, + ) + + const orchestrators = [...project.browserRpc.orchestrators.entries()] + + const promises: Promise[] = [] + + chunks.forEach((files, index) => { + if (orchestrators[index]) { + const [contextId, orchestrator] = orchestrators[index] + debug?.( + 'Reusing orchestrator (context %s) for files: %s', + contextId, + [...files.map(f => relative(project.config.root, f))].join(', '), + ) + const promise = waitForTests(contextId, project, files) + promises.push(promise) + orchestrator.createTesters(files) + } + else { + const contextId = crypto.randomUUID() + const waitPromise = waitForTests(contextId, project, files) + debug?.( + 'Opening a new context %s for files: %s', + contextId, + [...files.map(f => relative(project.config.root, f))].join(', '), + ) + const url = new URL('/', origin) + url.searchParams.set('contextId', contextId) + const page = provider.openPage(contextId, url.toString()).then(() => waitPromise) + promises.push(page) + } + }) + + await Promise.all(promises) } const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => { @@ -67,10 +111,29 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { groupedFiles.set(project, files) } + // TODO: paralellize tests instead of running them sequentially (based on CPU?) for (const [project, files] of groupedFiles.entries()) await runTests(project, files) } + const numCpus + = typeof nodeos.availableParallelism === 'function' + ? nodeos.availableParallelism() + : nodeos.cpus().length + + function getThreadsCount(project: WorkspaceProject) { + const config = project.config.browser + if (!config.headless || !project.browserProvider!.supportsParallelism) + return 1 + + if (!config.fileParallelism) + return 1 + + return ctx.config.watch + ? Math.max(Math.floor(numCpus / 2), 1) + : Math.max(numCpus - 1, 1) + } + return { name: 'browser', async close() { diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 2c203a572e6c..a07f93eec82a 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -80,11 +80,11 @@ export class WorkspaceProject { testers: new Map(), } - browserState: { + browserState = new Map void reject: (v: unknown) => void - } | undefined + }>() testFilesList: string[] | null = null diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 7b7f58d74095..85dd2e72a26e 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -9,8 +9,15 @@ export interface BrowserProviderInitializationOptions { export interface BrowserProvider { name: string + /** + * @experimental opt-in into file parallelisation + */ + supportsParallelism: boolean getSupportedBrowsers: () => readonly string[] - openPage: (url: string) => Awaitable + beforeCommand?: (command: string, args: unknown[]) => Awaitable + afterCommand?: (command: string, args: unknown[]) => Awaitable + getCommandsContext: (contextId: string) => Record + openPage: (contextId: string, url: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options initialize( @@ -78,6 +85,14 @@ export interface BrowserConfigOptions { */ isolate?: boolean + /** + * Run test files in parallel if provider supports this option + * This option only has effect in headless mode (enabled in CI by default) + * + * @default // Same as "test.fileParallelism" + */ + fileParallelism?: boolean + /** * Show Vitest UI * @@ -101,6 +116,13 @@ export interface BrowserConfigOptions { height: number } + /** + * Directory where screenshots will be saved when page.screenshot() is called + * If not set, all screenshots are saved to __screenshots__ directory in the same folder as the test file. + * If this is set, it will be resolved relative to the project root. + * @default __screenshots__ + */ + screenshotDirectory?: string /** * Scripts injected into the tester iframe. */ @@ -123,6 +145,7 @@ export interface BrowserCommandContext { testPath: string | undefined provider: BrowserProvider project: WorkspaceProject + contextId: string } export interface BrowserCommand { @@ -162,6 +185,7 @@ export interface ResolvedBrowserOptions extends BrowserConfigOptions { enabled: boolean headless: boolean isolate: boolean + fileParallelism: boolean api: ApiConfig ui: boolean viewport: { diff --git a/packages/vitest/src/utils/debugger.ts b/packages/vitest/src/utils/debugger.ts new file mode 100644 index 000000000000..c4d8871db775 --- /dev/null +++ b/packages/vitest/src/utils/debugger.ts @@ -0,0 +1,7 @@ +import createDebug from 'debug' + +export function createDebugger(namespace: `vitest:${string}`) { + const debug = createDebug(namespace) + if (debug.enabled) + return debug +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9124f298c9f0..9e92e5b52137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -438,6 +438,9 @@ importers: magic-string: specifier: ^0.30.10 version: 0.30.10 + msw: + specifier: ^2.3.1 + version: 2.3.1(typescript@5.4.5) sirv: specifier: ^2.0.4 version: 2.0.4 @@ -463,6 +466,9 @@ importers: flatted: specifier: ^3.3.1 version: 3.3.1 + pathe: + specifier: ^1.1.2 + version: 1.1.2 periscopic: specifier: ^4.0.2 version: 4.0.2 @@ -833,6 +839,9 @@ importers: packages/vitest: dependencies: + '@ampproject/remapping': + specifier: ^2.3.0 + version: 2.3.0 '@vitest/browser': specifier: workspace:* version: link:../browser @@ -891,9 +900,6 @@ importers: specifier: ^2.2.2 version: 2.2.2 devDependencies: - '@ampproject/remapping': - specifier: ^2.3.0 - version: 2.3.0 '@antfu/install-pkg': specifier: 0.3.1 version: 0.3.1 @@ -903,6 +909,9 @@ importers: '@sinonjs/fake-timers': specifier: 11.1.0 version: 11.1.0(patch_hash=trok5obk3l5tdlygozv34fknii) + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 '@types/estree': specifier: ^1.0.5 version: 1.0.5 @@ -3464,6 +3473,18 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: false + /@bundled-es-modules/cookie@2.0.0: + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + dependencies: + cookie: 0.5.0 + dev: false + + /@bundled-es-modules/statuses@1.0.1: + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + dependencies: + statuses: 2.0.1 + dev: false + /@canvas/image-data@1.0.0: resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==} dev: true @@ -4317,6 +4338,43 @@ packages: - supports-color dev: true + /@inquirer/confirm@3.1.9: + resolution: {integrity: sha512-UF09aejxCi4Xqm6N/jJAiFXArXfi9al52AFaSD+2uIHnhZGtd1d6lIGTRMPouVSJxbGEi+HkOWSYaiEY/+szUw==} + engines: {node: '>=18'} + dependencies: + '@inquirer/core': 8.2.2 + '@inquirer/type': 1.3.3 + dev: false + + /@inquirer/core@8.2.2: + resolution: {integrity: sha512-K8SuNX45jEFlX3EBJpu9B+S2TISzMPGXZIuJ9ME924SqbdW6Pt6fIkKvXg7mOEOKJ4WxpQsxj0UTfcL/A434Ww==} + engines: {node: '>=18'} + dependencies: + '@inquirer/figures': 1.0.3 + '@inquirer/type': 1.3.3 + '@types/mute-stream': 0.0.4 + '@types/node': 20.14.2 + '@types/wrap-ansi': 3.0.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-spinners: 2.9.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: false + + /@inquirer/figures@1.0.3: + resolution: {integrity: sha512-ErXXzENMH5pJt5/ssXV0DfWUZqly8nGzf0UcBV9xTnP+KyffE2mqyxIMBrZ8ijQck2nU0TQm40EQB53YreyWHw==} + engines: {node: '>=18'} + dev: false + + /@inquirer/type@1.3.3: + resolution: {integrity: sha512-xTUt0NulylX27/zMx04ZYar/kr1raaiFTVvQ5feljQsiAgdm0WPj4S73/ye0fbslh+15QrIuDvfCXTek7pMY5A==} + engines: {node: '>=18'} + dev: false + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -4440,6 +4498,23 @@ packages: '@lit-labs/ssr-dom-shim': 1.1.2 dev: false + /@mswjs/cookies@1.1.0: + resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} + engines: {node: '>=18'} + dev: false + + /@mswjs/interceptors@0.29.1: + resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==} + engines: {node: '>=18'} + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.2 + strict-event-emitter: 0.5.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4462,6 +4537,21 @@ packages: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} dev: true + /@open-draft/deferred-promise@2.2.0: + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + dev: false + + /@open-draft/logger@0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.2 + dev: false + + /@open-draft/until@2.1.0: + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5444,6 +5534,10 @@ packages: resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} dev: true + /@types/cookie@0.6.0: + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + dev: false + /@types/d3-force@3.0.9: resolution: {integrity: sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==} dev: true @@ -5610,6 +5704,12 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true + /@types/mute-stream@0.0.4: + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + dependencies: + '@types/node': 20.14.2 + dev: false + /@types/natural-compare@1.4.3: resolution: {integrity: sha512-XCAxy+Gg6+S6VagwzcknnvCKujj/bVv1q+GFuCrFEelqaZPqJoC+FeXLwc2dp+oLP7qDZQ4ZfQiTJQ9sIUmlLw==} dev: true @@ -5689,6 +5789,10 @@ packages: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true + /@types/statuses@2.0.5: + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + dev: false + /@types/tern@0.23.4: resolution: {integrity: sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==} dependencies: @@ -5733,6 +5837,10 @@ packages: resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} dev: true + /@types/wrap-ansi@3.0.0: + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + dev: false + /@types/ws@8.5.10: resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} dependencies: @@ -7093,7 +7201,6 @@ packages: engines: {node: '>=8'} dependencies: type-fest: 0.21.3 - dev: true /ansi-escapes@5.0.0: resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} @@ -7874,6 +7981,11 @@ packages: engines: {node: '>=6'} dev: true + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: false + /cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -7887,6 +7999,11 @@ packages: engines: {node: '>= 12'} dev: true + /cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + dev: false + /cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -7894,7 +8011,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true /clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} @@ -8098,7 +8214,6 @@ packages: /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - dev: true /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} @@ -10152,7 +10267,6 @@ packages: /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: true /get-east-asian-width@1.2.0: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} @@ -10457,6 +10571,11 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + /gtoken@7.0.1: resolution: {integrity: sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==} engines: {node: '>=14.0.0'} @@ -10555,6 +10674,10 @@ packages: hasBin: true dev: true + /headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + dev: false + /hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -10976,6 +11099,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -12495,6 +12622,37 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /msw@2.3.1(typescript@5.4.5): + resolution: {integrity: sha512-ocgvBCLn/5l3jpl1lssIb3cniuACJLoOfZu01e3n5dbJrpA5PeeWn28jCLgQDNt6d7QT8tF2fYRzm9JoEHtiig==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + peerDependencies: + typescript: '>= 4.7.x' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/statuses': 1.0.1 + '@inquirer/confirm': 3.1.9 + '@mswjs/cookies': 1.1.0 + '@mswjs/interceptors': 0.29.1 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + chalk: 4.1.2 + graphql: 16.8.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.2 + path-to-regexp: 6.2.2 + strict-event-emitter: 0.5.1 + type-fest: 4.20.0 + typescript: 5.4.5 + yargs: 17.7.2 + dev: false + /muggle-string@0.3.1: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} dev: true @@ -12506,7 +12664,6 @@ packages: /mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - dev: true /n12@0.4.0: resolution: {integrity: sha512-p/hj4zQ8d3pbbFLQuN1K9honUxiDDhueOWyFLw/XgBv+wZCE44bcLH4CIcsolOceJQduh4Jf7m/LfaTxyGmGtQ==} @@ -12808,6 +12965,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /outvariant@1.4.2: + resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + dev: false + /p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -13005,6 +13166,10 @@ packages: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: true + /path-to-regexp@6.2.2: + resolution: {integrity: sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -13665,7 +13830,6 @@ packages: /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: true /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} @@ -14437,7 +14601,6 @@ packages: /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - dev: true /std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -14462,6 +14625,10 @@ packages: queue-tick: 1.0.1 dev: true + /strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + dev: false + /string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -15093,7 +15260,6 @@ packages: /type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - dev: true /type-fest@0.6.0: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} @@ -15120,6 +15286,11 @@ packages: engines: {node: '>=12.20'} dev: true + /type-fest@4.20.0: + resolution: {integrity: sha512-MBh+PHUHHisjXf4tlx0CFWoMdjx8zCMLJHOjnV1prABYZFHqtFOyauCIK2/7w4oIfwkF8iNhLtnJEfVY2vn3iw==} + engines: {node: '>=16'} + dev: false + /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -16522,7 +16693,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -16621,7 +16791,6 @@ packages: /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - dev: true /yallist@2.1.2: resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} @@ -16651,7 +16820,6 @@ packages: /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - dev: true /yargs@17.7.1: resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==} @@ -16677,7 +16845,6 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - dev: true /yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png new file mode 100644 index 000000000000..f880ae781001 Binary files /dev/null and b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png differ diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 034f3a32ae2a..0eb2f150540c 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -1,15 +1,20 @@ -import { expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import { page } from '@vitest/browser/context' import { createNode } from '#src/createNode' import '../src/button.css' -test('renders div', async () => { - await page.viewport(1500, 600) - document.body.style.background = '#f3f3f3' - const wrapper = document.createElement('div') - wrapper.className = 'wrapper' - document.body.appendChild(wrapper) - const div = createNode() - wrapper.appendChild(div) - expect(div.textContent).toBe('Hello World!') +describe('dom related activity', () => { + test('renders div', async () => { + document.body.style.background = '#f3f3f3' + const wrapper = document.createElement('div') + wrapper.className = 'wrapper' + document.body.appendChild(wrapper) + const div = createNode() + wrapper.appendChild(div) + expect(div.textContent).toBe('Hello World!') + const screenshotPath = await page.screenshot({ + element: wrapper, + }) + expect(screenshotPath).toMatch(/__screenshots__\/dom.test.ts\/dom-related-activity-renders-div-1.png/) + }) })