diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 6d18fa7f9674..9282c84dcc17 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -8,7 +8,26 @@ This page provides information about the experimental browser mode feature in th ## Installation -By default, Browser Mode doesn't require any additional E2E provider to run tests locally because it reuses your existing browser. +For easier setup, you can use `vitest init browser` command to install required dependencies and create browser configuration. + +::: code-group +```bash [npm] +npx vitest init browser +``` +```bash [yarn] +yarn exec vitest init browser +``` +```bash [pnpm] +pnpx vitest init browser +``` +```bash [bun] +bunx vitest init browser +``` +::: + +### Manual Installation + +You can also install packages manually. By default, Browser Mode doesn't require any additional E2E provider to run tests locally because it reuses your existing browser. ::: code-group ```bash [npm] @@ -997,7 +1016,7 @@ We recommend using `testing-library` packages depending on your framework: - [`@testing-library/svelte`](https://testing-library.com/docs/svelte-testing-library/intro) to render [svelte](https://svelte.dev) components - [`@testing-library/react`](https://testing-library.com/docs/react-testing-library/intro) to render [react](https://react.dev) components - [`@testing-library/preact`](https://testing-library.com/docs/preact-testing-library/intro) to render [preact](https://preactjs.com) components -- [`@testing-library/solid`](https://testing-library.com/docs/solid-testing-library/intro) to render [solid](https://www.solidjs.com) components +- [`solid-testing-library`](https://testing-library.com/docs/solid-testing-library/intro) to render [solid](https://www.solidjs.com) components - [`@marko/testing-library`](https://testing-library.com/docs/marko-testing-library/intro) to render [marko](https://markojs.com) components ::: warning diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index d183be726642..952dba0a6528 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -1,3 +1,4 @@ +import type { CancelReason } from '@vitest/runner' import { getBrowserState } from './utils' export interface IframeDoneEvent { @@ -59,6 +60,13 @@ export interface IframeMockInvalidateEvent { type: 'mock:invalidate' } +export interface GlobalChannelTestRunCanceledEvent { + type: 'cancel' + reason: CancelReason +} + +export type GlobalChannelIncomingEvent = GlobalChannelTestRunCanceledEvent + export type IframeChannelIncomingEvent = | IframeViewportEvent | IframeErrorEvent @@ -81,6 +89,7 @@ export type IframeChannelEvent = export const channel = new BroadcastChannel( `vitest:${getBrowserState().contextId}`, ) +export const globalChannel = new BroadcastChannel('vitest:global') export function waitForChannel(event: IframeChannelOutgoingEvent['type']) { return new Promise((resolve) => { diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index c36696d21b3e..80b11efd2447 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -2,175 +2,204 @@ import type { ResolvedConfig } from 'vitest' import { generateHash } from '@vitest/runner/utils' import { relative } from 'pathe' import { channel, client } from './client' -import { rpcDone } from './tester/rpc' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' -import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' +import { type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent, globalChannel } from './channel' import { createModuleMocker } from './tester/msw' const url = new URL(location.href) - const ID_ALL = '__vitest_all__' -const iframes = new Map() +class IframeOrchestrator { + private cancelled = false + private runningFiles = new Set() + private mocker = createModuleMocker() + private iframes = new Map() -let promiseTesters: Promise | undefined -getBrowserState().createTesters = async (files) => { - await promiseTesters - promiseTesters = createTesters(files).finally(() => { - promiseTesters = undefined - }) - await promiseTesters -} + public async init() { + const testFiles = getBrowserState().files -function debug(...args: unknown[]) { - const debug = getConfig().env.VITEST_BROWSER_DEBUG - if (debug && debug !== 'false') { - client.rpc.debug(...args.map(String)) - } -} + debug('test files', testFiles.join(', ')) + + this.runningFiles.clear() + testFiles.forEach(file => this.runningFiles.add(file)) -function createIframe(container: HTMLDivElement, file: string) { - if (iframes.has(file)) { - iframes.get(file)!.remove() - iframes.delete(file) + channel.addEventListener( + 'message', + e => this.onIframeEvent(e), + ) + globalChannel.addEventListener( + 'message', + e => this.onGlobalChannelEvent(e), + ) } - const iframe = document.createElement('iframe') - iframe.setAttribute('loading', 'eager') - iframe.setAttribute( - 'src', - `${url.pathname}__vitest_test__/__test__/${ - getBrowserState().contextId - }/${encodeURIComponent(file)}`, - ) - iframe.setAttribute('data-vitest', 'true') - - iframe.style.display = 'block' - iframe.style.border = 'none' - iframe.style.zIndex = '1' - iframe.style.position = 'relative' - iframe.setAttribute('allowfullscreen', 'true') - iframe.setAttribute('allow', 'clipboard-write;') - iframe.setAttribute('name', 'vitest-iframe') - - iframes.set(file, iframe) - container.appendChild(iframe) - return iframe -} + public async createTesters(testFiles: string[]) { + this.cancelled = false + this.runningFiles.clear() + testFiles.forEach(file => this.runningFiles.add(file)) -async function done() { - await rpcDone() - await client.rpc.finishBrowserTests(getBrowserState().contextId) -} + const config = getConfig() + const container = await getContainer(config) -async function getContainer(config: ResolvedConfig): Promise { - if (config.browser.ui) { - const element = document.querySelector('#tester-ui') - if (!element) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(getContainer(config)) - }, 30) + if (config.browser.ui) { + container.className = 'scrolls' + container.textContent = '' + } + const { width, height } = config.browser.viewport + + this.iframes.forEach(iframe => iframe.remove()) + this.iframes.clear() + + if (config.isolate === false) { + const iframe = this.createIframe(container, ID_ALL) + + await setIframeViewport(iframe, width, height) + return + } + + for (const file of testFiles) { + if (this.cancelled) { + done() + return + } + + const iframe = this.createIframe(container, file) + + await setIframeViewport(iframe, width, height) + + await new Promise((resolve) => { + channel.addEventListener( + 'message', + function handler(e: MessageEvent) { + // done and error can only be triggered by the previous iframe + if (e.data.type === 'done' || e.data.type === 'error') { + channel.removeEventListener('message', handler) + resolve() + } + }, + ) }) } - return element as HTMLDivElement } - return document.querySelector('#vitest-tester') as HTMLDivElement -} -const runningFiles = new Set() + private createIframe(container: HTMLDivElement, file: string) { + if (this.iframes.has(file)) { + this.iframes.get(file)!.remove() + this.iframes.delete(file) + } -client.ws.addEventListener('open', async () => { - const testFiles = getBrowserState().files + const iframe = document.createElement('iframe') + iframe.setAttribute('loading', 'eager') + iframe.setAttribute( + 'src', + `${url.pathname}__vitest_test__/__test__/${ + getBrowserState().contextId + }/${encodeURIComponent(file)}`, + ) + iframe.setAttribute('data-vitest', 'true') - debug('test files', testFiles.join(', ')) - - runningFiles.clear() - testFiles.forEach(file => runningFiles.add(file)) - - const mocker = createModuleMocker() - - channel.addEventListener( - 'message', - async (e: MessageEvent): Promise => { - debug('channel event', JSON.stringify(e.data)) - switch (e.data.type) { - case 'viewport': { - const { width, height, id } = e.data - const iframe = iframes.get(id) - if (!iframe) { - const error = new Error(`Cannot find iframe with id ${id}`) - channel.postMessage({ - type: 'viewport:fail', - id, - error: error.message, - }) - await client.rpc.onUnhandledError( - { - name: 'Teardown Error', - message: error.message, - }, - 'Teardown Error', - ) - return - } - await setIframeViewport(iframe, width, height) - channel.postMessage({ type: 'viewport:done', id }) - break + iframe.style.display = 'block' + iframe.style.border = 'none' + iframe.style.zIndex = '1' + iframe.style.position = 'relative' + iframe.setAttribute('allowfullscreen', 'true') + iframe.setAttribute('allow', 'clipboard-write;') + iframe.setAttribute('name', 'vitest-iframe') + + this.iframes.set(file, iframe) + container.appendChild(iframe) + return iframe + } + + private async onGlobalChannelEvent(e: MessageEvent) { + debug('global channel event', JSON.stringify(e.data)) + switch (e.data.type) { + case 'cancel': { + this.cancelled = true + break + } + } + } + + private async onIframeEvent(e: MessageEvent) { + debug('iframe event', JSON.stringify(e.data)) + switch (e.data.type) { + case 'viewport': { + const { width, height, id } = e.data + const iframe = this.iframes.get(id) + if (!iframe) { + const error = new Error(`Cannot find iframe with id ${id}`) + channel.postMessage({ + type: 'viewport:fail', + id, + error: error.message, + }) + await client.rpc.onUnhandledError( + { + name: 'Teardown Error', + message: error.message, + }, + 'Teardown Error', + ) + return } - case 'done': { - const filenames = e.data.filenames - filenames.forEach(filename => runningFiles.delete(filename)) - - if (!runningFiles.size) { - const ui = getUiAPI() - // in isolated mode we don't change UI because it will slow down tests, - // so we only select it when the run is done - if (ui && filenames.length > 1) { - const id = generateFileId(filenames[filenames.length - 1]) - ui.setCurrentFileId(id) - } - await done() - } - else { - // keep the last iframe - const iframeId = e.data.id - iframes.get(iframeId)?.remove() - iframes.delete(iframeId) + await setIframeViewport(iframe, width, height) + channel.postMessage({ type: 'viewport:done', id }) + break + } + case 'done': { + const filenames = e.data.filenames + filenames.forEach(filename => this.runningFiles.delete(filename)) + + if (!this.runningFiles.size) { + const ui = getUiAPI() + // in isolated mode we don't change UI because it will slow down tests, + // so we only select it when the run is done + if (ui && filenames.length > 1) { + const id = generateFileId(filenames[filenames.length - 1]) + ui.setCurrentFileId(id) } - break + await done() } - // error happened at the top level, this should never happen in user code, but it can trigger during development - case 'error': { + else { + // keep the last iframe const iframeId = e.data.id - iframes.delete(iframeId) - await client.rpc.onUnhandledError(e.data.error, e.data.errorType) - if (iframeId === ID_ALL) { - runningFiles.clear() - } - else { - runningFiles.delete(iframeId) - } - if (!runningFiles.size) { - await done() - } - break + this.iframes.get(iframeId)?.remove() + this.iframes.delete(iframeId) + } + break + } + // error happened at the top level, this should never happen in user code, but it can trigger during development + case 'error': { + const iframeId = e.data.id + this.iframes.delete(iframeId) + await client.rpc.onUnhandledError(e.data.error, e.data.errorType) + if (iframeId === ID_ALL) { + this.runningFiles.clear() } - 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: { + else { + this.runningFiles.delete(iframeId) + } + if (!this.runningFiles.size) { + await done() + } + break + } + case 'mock:invalidate': + this.mocker.invalidate() + break + case 'unmock': + await this.mocker.unmock(e.data) + break + case 'mock': + await this.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( @@ -181,62 +210,52 @@ client.ws.addEventListener('open', async () => { 'Unexpected Event', ) await done() - } } - }, - ) - - // if page was refreshed, there will be no test files - // createTesters will be called again when tests are running in the UI - if (testFiles.length) { - await createTesters(testFiles) + } } -}) +} -async function createTesters(testFiles: string[]) { - runningFiles.clear() - testFiles.forEach(file => runningFiles.add(file)) +const orchestrator = new IframeOrchestrator() - const config = getConfig() - const container = await getContainer(config) +let promiseTesters: Promise | undefined +getBrowserState().createTesters = async (files) => { + await promiseTesters + promiseTesters = orchestrator.createTesters(files).finally(() => { + promiseTesters = undefined + }) + await promiseTesters +} + +async function done() { + await client.rpc.finishBrowserTests(getBrowserState().contextId) +} +async function getContainer(config: ResolvedConfig): Promise { if (config.browser.ui) { - container.className = 'scrolls' - container.textContent = '' + const element = document.querySelector('#tester-ui') + if (!element) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(getContainer(config)) + }, 30) + }) + } + return element as HTMLDivElement } - const { width, height } = config.browser.viewport - - iframes.forEach(iframe => iframe.remove()) - iframes.clear() - - if (config.isolate === false) { - const iframe = createIframe(container, ID_ALL) + return document.querySelector('#vitest-tester') as HTMLDivElement +} - await setIframeViewport(iframe, width, height) - } - else { - // otherwise, we need to wait for each iframe to finish before creating the next one - // this is the most stable way to run tests in the browser - for (const file of testFiles) { - const iframe = createIframe(container, file) +client.ws.addEventListener('open', async () => { + const testFiles = getBrowserState().files - await setIframeViewport(iframe, width, height) + await orchestrator.init() - await new Promise((resolve) => { - channel.addEventListener( - 'message', - function handler(e: MessageEvent) { - // done and error can only be triggered by the previous iframe - if (e.data.type === 'done' || e.data.type === 'error') { - channel.removeEventListener('message', handler) - resolve() - } - }, - ) - }) - } + // if page was refreshed, there will be no test files + // createTesters will be called again when tests are running in the UI + if (testFiles.length) { + await orchestrator.createTesters(testFiles) } -} +}) function generateFileId(file: string) { const config = getConfig() @@ -259,3 +278,10 @@ async function setIframeViewport( iframe.style.height = `${height}px` } } + +function debug(...args: unknown[]) { + const debug = getConfig().env.VITEST_BROWSER_DEBUG + if (debug && debug !== 'false') { + client.rpc.debug(...args.map(String)) + } +} diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 7eefd080532f..ec1430e2b970 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -1,10 +1,11 @@ -import type { File, Suite, Task, TaskResultPack, VitestRunner } from '@vitest/runner' +import type { CancelReason, File, Suite, Task, TaskResultPack, VitestRunner } from '@vitest/runner' import type { ResolvedConfig, WorkerGlobalState } from 'vitest' import type { VitestExecutor } from 'vitest/execute' import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser' import { TraceMap, originalPositionFor } from 'vitest/utils' import { importId } from '../utils' +import { globalChannel } from '../channel' import { VitestBrowserSnapshotEnvironment } from './snapshot' import { rpc } from './rpc' import type { VitestBrowserClientMocker } from './mocker' @@ -47,11 +48,16 @@ export function createBrowserRunner( if (currentFailures >= this.config.bail) { rpc().onCancel('test-failure') - this.onCancel?.('test-failure') + this.onCancel('test-failure') } } } + onCancel = (reason: CancelReason) => { + super.onCancel?.(reason) + globalChannel.postMessage({ type: 'cancel', reason }) + } + onBeforeRunSuite = async (suite: Suite | File) => { await Promise.all([ super.onBeforeRunSuite?.(suite), diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index dbc23d12b4cd..24f6d6b8f070 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -113,7 +113,17 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { file => getFilePoolName(project, file) === 'browser', ) const setupFiles = toArray(project.config.setupFiles) + + // replace env values - cannot be reassign at runtime + const define: Record = {} + for (const env in (project.config.env || {})) { + const stringValue = JSON.stringify(project.config.env[env]) + define[`process.env.${env}`] = stringValue + define[`import.meta.env.${env}`] = stringValue + } + return { + define, optimizeDeps: { entries: [ ...browserTestFiles, @@ -262,9 +272,22 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { }, // TODO: remove this when @testing-library/vue supports ESM { - name: 'vitest:browser:support-vue-testing-library', + name: 'vitest:browser:support-testing-library', config() { return { + define: { + // testing-library/preact + 'process.env.PTL_SKIP_AUTO_CLEANUP': !!process.env.PTL_SKIP_AUTO_CLEANUP, + // testing-library/react + 'process.env.RTL_SKIP_AUTO_CLEANUP': !!process.env.RTL_SKIP_AUTO_CLEANUP, + 'process.env?.RTL_SKIP_AUTO_CLEANUP': !!process.env.RTL_SKIP_AUTO_CLEANUP, + // testing-library/svelte, testing-library/solid + 'process.env.STL_SKIP_AUTO_CLEANUP': !!process.env.STL_SKIP_AUTO_CLEANUP, + // testing-library/vue + 'process.env.VTL_SKIP_AUTO_CLEANUP': !!process.env.VTL_SKIP_AUTO_CLEANUP, + // dom.debug() + 'process.env.DEBUG_PRINT_LIMIT': process.env.DEBUG_PRINT_LIMIT || 7000, + }, optimizeDeps: { esbuildOptions: { plugins: [ diff --git a/packages/browser/src/node/pool.ts b/packages/browser/src/node/pool.ts index 2bcb1aac29ee..e0b6b53a6bcc 100644 --- a/packages/browser/src/node/pool.ts +++ b/packages/browser/src/node/pool.ts @@ -21,18 +21,8 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const runTests = async (project: WorkspaceProject, files: string[]) => { ctx.state.clearFiles(project, files) const browser = project.browser! - // 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(() => { - // isCancelled = true - // }) const provider = browser.provider providers.add(provider) @@ -109,8 +99,17 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { groupedFiles.set(project, files) } + let isCancelled = false + ctx.onCancel(() => { + isCancelled = true + }) + // TODO: paralellize tests instead of running them sequentially (based on CPU?) for (const [project, files] of groupedFiles.entries()) { + if (isCancelled) { + break + } + await runTests(project, files) } } diff --git a/packages/vitest/src/create/browser/creator.ts b/packages/vitest/src/create/browser/creator.ts new file mode 100644 index 000000000000..cee94bbb3900 --- /dev/null +++ b/packages/vitest/src/create/browser/creator.ts @@ -0,0 +1,516 @@ +import { dirname, relative, resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import prompt from 'prompts' +import c from 'picocolors' +import type { Agent } from '@antfu/install-pkg' +import { detectPackageManager, installPackage } from '@antfu/install-pkg' +import { findUp } from 'find-up' +import { execa } from 'execa' +import type { BrowserBuiltinProvider } from '../../types/browser' +import { configFiles } from '../../constants' +import { generateExampleFiles } from './examples' + +// eslint-disable-next-line no-console +const log = console.log + +function getProviderOptions(): prompt.Choice[] { + const providers: Record = { + playwright: 'Playwright relies on Chrome DevTools protocol. Read more: https://playwright.dev', + webdriverio: 'WebdriverIO uses WebDriver protocol. Read more: https://webdriver.io', + preview: 'Preview is useful to quickly run your tests in the browser, but not suitable for CI.', + } + + return Object.entries(providers).map(([provider, description]) => { + return { + title: provider, + description, + value: provider, + } + }) +} + +function getBrowserNames(provider: BrowserBuiltinProvider) { + switch (provider) { + case 'webdriverio': + return ['chrome', 'firefox', 'edge', 'safari'] + case 'playwright': + return ['chromium', 'firefox', 'webkit'] + case 'preview': + return ['chrome', 'firefox', 'safari'] + } +} + +function getProviderPackageNames(provider: BrowserBuiltinProvider) { + switch (provider) { + case 'webdriverio': + return { + types: '@vitest/browser/providers/webdriverio', + pkg: 'webdriverio', + } + case 'playwright': + return { + types: '@vitest/browser/providers/playwright', + pkg: 'playwright', + } + case 'preview': + return { + types: '@vitest/browser/matchers', + pkg: null, + } + } + throw new Error(`Unsupported provider: ${provider}`) +} + +function getFramework(): prompt.Choice[] { + return [ + { + title: 'vanilla', + value: 'vanilla', + description: 'No framework, just plain JavaScript or TypeScript.', + }, + { + title: 'vue', + value: 'vue', + description: '"The Progressive JavaScript Framework"', + }, + { + title: 'svelte', + value: 'svelte', + description: '"Svelte: cybernetically enhanced web apps"', + }, + { + title: 'react', + value: 'react', + description: '"The library for web and native user interfaces"', + }, + { + title: 'preact', + value: 'preact', + description: '"Fast 3kB alternative to React with the same modern API"', + }, + { + title: 'solid', + value: 'solid', + description: '"Simple and performant reactivity for building user interfaces"', + }, + { + title: 'marko', + value: 'marko', + description: '"A declarative, HTML-based language that makes building web apps fun"', + }, + ] +} + +function getFrameworkTestPackage(framework: string) { + switch (framework) { + case 'vanilla': + return '@testing-library/dom' + case 'vue': + return '@testing-library/vue' + case 'svelte': + return '@testing-library/svelte' + case 'react': + return '@testing-library/react' + case 'preact': + return '@testing-library/preact' + case 'solid': + return 'solid-testing-library' + case 'marko': + return '@marko/testing-library' + } + throw new Error(`Unsupported framework: ${framework}`) +} + +function getFrameworkPluginPackage(framework: string) { + switch (framework) { + case 'vue': + return '@vitejs/plugin-vue' + case 'svelte': + return '@sveltejs/vite-plugin-svelte' + case 'react': + return '@vitejs/plugin-react' + case 'preact': + return '@preact/preset-vite' + case 'solid': + return 'vite-plugin-solid' + case 'marko': + return '@marko/vite' + } + return null +} + +async function updateTsConfig(type: string | undefined | null) { + if (type == null) { + return + } + const msg = `Add "${c.bold(type)}" to your tsconfig.json "${c.bold('compilerOptions.types')}" field to have better intellisense support.` + log() + log(c.yellow('◼'), c.yellow(msg)) +} + +function getLanguageOptions(): prompt.Choice[] { + return [ + { + title: 'TypeScript', + description: 'Use TypeScript.', + value: 'ts', + }, + { + title: 'JavaScript', + description: 'Use plain JavaScript.', + value: 'js', + }, + ] +} + +async function installPackages(pkgManager: string | null, packages: string[]) { + if (!packages.length) { + log(c.green('✔'), c.bold('All packages are already installed.')) + return + } + + log(c.cyan('◼'), c.bold('Installing packages...')) + log(c.cyan('◼'), packages.join(', ')) + + log() + await installPackage(packages, { dev: true, packageManager: pkgManager ?? undefined }) +} + +function readPkgJson(path: string) { + if (!existsSync(path)) { + return null + } + const content = readFileSync(path, 'utf-8') + return JSON.parse(content) +} + +function getPossibleDefaults(dependencies: any) { + const provider = getPossibleProvider(dependencies) + const framework = getPossibleFramework(dependencies) + return { + lang: 'ts', + provider, + framework, + } +} + +function getPossibleFramework(dependencies: Record) { + if (dependencies.vue || dependencies['vue-tsc'] || dependencies['@vue/reactivity']) { + return 'vue' + } + if (dependencies.react || dependencies['react-dom']) { + return 'react' + } + if (dependencies.svelte || dependencies['@sveltejs/kit']) { + return 'svelte' + } + if (dependencies.preact) { + return 'preact' + } + if (dependencies['solid-js'] || dependencies['@solidjs/start']) { + return 'solid' + } + if (dependencies.marko) { + return 'marko' + } + return 'vanilla' +} + +function getPossibleProvider(dependencies: Record) { + if (dependencies.webdriverio || dependencies['@wdio/cli'] || dependencies['@wdio/config']) { + return 'webdriverio' + } + // playwright is the default recommendation + return 'playwright' +} + +function getProviderDocsLink(provider: string) { + switch (provider) { + case 'playwright': + return 'https://playwright.dev' + case 'webdriverio': + return 'https://webdriver.io' + } +} + +function sort(choices: prompt.Choice[], value: string | undefined) { + const index = choices.findIndex(i => i.value === value) + if (index === -1) { + return choices + } + const item = choices.splice(index, 1)[0] + return [item, ...choices] +} + +function fail() { + process.exitCode = 1 +} + +async function generateWorkspaceFile(options: { + configPath: string + rootConfig: string + provider: string + browser: string +}) { + const relativeRoot = relative(dirname(options.configPath), options.rootConfig) + const workspaceContent = [ + `import { defineWorkspace } from 'vitest/config'`, + '', + 'export default defineWorkspace([', + ' // This will keep running your existing tests.', + ' // If you don\'t need to run those in Node.js anymore,', + ' // You can safely remove it from the workspace file', + ' // Or move the browser test configuration to the config file.', + ` '${relativeRoot}',`, + ` {`, + ` extends: '${relativeRoot}',`, + ` test: {`, + ` browser: {`, + ` enabled: true,`, + ` name: '${options.browser}',`, + ` provider: '${options.provider}',`, + options.provider !== 'preview' && ` // ${getProviderDocsLink(options.provider)}`, + options.provider !== 'preview' && ` providerOptions: {},`, + ` },`, + ` },`, + ` },`, + `])`, + '', + ].filter(c => c != null).join('\n') + await writeFile(options.configPath, workspaceContent) +} + +async function generateFrameworkConfigFile(options: { + configPath: string + framework: string + frameworkPlugin: string | null + provider: string + browser: string +}) { + const frameworkImport = options.framework === 'svelte' + ? `import { svelte } from '${options.frameworkPlugin}'` + : `import ${options.framework} from '${options.frameworkPlugin}'` + const configContent = [ + `import { defineConfig } from 'vitest/config'`, + options.frameworkPlugin ? frameworkImport : null, + ``, + 'export default defineConfig({', + options.frameworkPlugin ? ` plugins: [${options.framework}()],` : null, + ` test: {`, + ` browser: {`, + ` enabled: true,`, + ` name: '${options.browser}',`, + ` provider: '${options.provider}',`, + options.provider !== 'preview' && ` // ${getProviderDocsLink(options.provider)}`, + options.provider !== 'preview' && ` providerOptions: {},`, + ` },`, + ` },`, + `})`, + '', + ].join('\n') + // this file is only generated if there is already NO root config which is an edge case + await writeFile(options.configPath, configContent) +} + +async function updatePkgJsonScripts(pkgJsonPath: string, vitestScript: string) { + if (!existsSync(pkgJsonPath)) { + const pkg = { + scripts: { + 'test:browser': vitestScript, + }, + } + await writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') + } + else { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) + pkg.scripts = pkg.scripts || {} + pkg.scripts['test:browser'] = vitestScript + await writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') + } + log(c.green('✔'), 'Added "test:browser" script to your package.json.') +} + +function getRunScript(pkgManager: Agent | null) { + switch (pkgManager) { + case 'yarn@berry': + case 'yarn': + return 'yarn test:browser' + case 'pnpm@6': + case 'pnpm': + return 'pnpm test:browser' + case 'bun': + return 'bun test:browser' + default: + return 'npm run test:browser' + } +} + +function getPlaywrightRunArgs(pkgManager: Agent | null) { + switch (pkgManager) { + case 'yarn@berry': + case 'yarn': + return ['yarn', 'exec'] + case 'pnpm@6': + case 'pnpm': + return ['pnpx'] + case 'bun': + return ['bunx'] + default: + return ['npx'] + } +} + +export async function create() { + log(c.cyan('◼'), 'This utility will help you set up a browser testing environment.\n') + + const pkgJsonPath = resolve(process.cwd(), 'package.json') + const pkg = readPkgJson(pkgJsonPath) || {} + const dependencies = { + ...pkg.dependencies, + ...pkg.devDependencies, + } + + const defaults = getPossibleDefaults(dependencies) + + const { lang } = await prompt({ + type: 'select', + name: 'lang', + message: 'Choose a language for your tests', + choices: sort(getLanguageOptions(), defaults?.lang), + }) + + if (!lang) { + return fail() + } + + const { provider } = await prompt({ + type: 'select', + name: 'provider', + message: 'Choose a browser provider. Vitest will use its API to control the testing environment', + choices: sort(getProviderOptions(), defaults?.provider), + }) + if (!provider) { + return fail() + } + + const { browser } = await prompt({ + type: 'select', + name: 'browser', + message: 'Choose a browser', + choices: getBrowserNames(provider).map(browser => ({ + title: browser, + value: browser, + })), + }) + if (!provider) { + return fail() + } + + const { framework } = await prompt({ + type: 'select', + name: 'framework', + message: 'Choose your framework', + choices: sort(getFramework(), defaults?.framework), + }) + if (!framework) { + return fail() + } + + let installPlaywright = false + if (provider === 'playwright') { + ;({ installPlaywright } = await prompt({ + type: 'confirm', + name: 'installPlaywright', + message: `Install Playwright browsers (can be done manually via 'pnpm exec playwright install')?`, + })) + } + if (installPlaywright == null) { + return fail() + } + + const dependenciesToInstall = [ + '@vitest/browser', + getFrameworkTestPackage(framework), + ] + + const providerPkg = getProviderPackageNames(provider) + if (providerPkg.pkg) { + dependenciesToInstall.push(providerPkg.pkg) + } + const frameworkPlugin = getFrameworkPluginPackage(framework) + if (frameworkPlugin) { + dependenciesToInstall.push(frameworkPlugin) + } + + const pkgManager = await detectPackageManager() + + log() + await installPackages( + pkgManager, + dependenciesToInstall.filter(pkg => !dependencies[pkg]), + ) + + const rootConfig = await findUp(configFiles, { + cwd: process.cwd(), + }) + + let scriptCommand = 'vitest' + + log() + + if (rootConfig) { + let browserWorkspaceFile = resolve(dirname(rootConfig), `vitest.workspace.${lang}`) + if (existsSync(browserWorkspaceFile)) { + log(c.yellow('⚠'), c.yellow('A workspace file already exists. Creating a new one for the browser tests - you can merge them manually if needed.')) + browserWorkspaceFile = resolve(process.cwd(), `vitest.workspace.browser.${lang}`) + } + scriptCommand = `vitest --workspace=${relative(process.cwd(), browserWorkspaceFile)}` + await generateWorkspaceFile({ + configPath: browserWorkspaceFile, + rootConfig, + provider, + browser, + }) + log(c.green('✔'), 'Created a workspace file for browser tests:', c.bold(relative(process.cwd(), browserWorkspaceFile))) + } + else { + const configPath = resolve(process.cwd(), `vitest.config.${lang}`) + await generateFrameworkConfigFile({ + configPath, + framework, + frameworkPlugin, + provider, + browser, + }) + log(c.green('✔'), 'Created a config file for browser tests', c.bold(relative(process.cwd(), configPath))) + } + + log() + await updatePkgJsonScripts(pkgJsonPath, scriptCommand) + + if (installPlaywright) { + log() + const [command, ...args] = getPlaywrightRunArgs(pkgManager) + const allArgs = [...args, 'playwright', 'install', '--with-deps'] + log(c.cyan('◼'), `Installing Playwright dependencies with \`${c.bold(command)} ${c.bold(allArgs.join(' '))}\`...`) + log() + await execa(command, allArgs, { + stdout: 'inherit', + stderr: 'inherit', + }) + } + + // TODO: can we do this ourselved? + if (lang === 'ts') { + await updateTsConfig(providerPkg?.types) + } + + log() + const exampleTestFile = await generateExampleFiles(framework, lang) + log(c.green('✔'), 'Created example test file in', c.bold(relative(process.cwd(), exampleTestFile))) + log(c.dim(' You can safely delete this file once you have written your own tests.')) + + log() + log(c.cyan('◼'), 'All done! Run your tests with', c.bold(getRunScript(pkgManager))) +} diff --git a/packages/vitest/src/create/browser/examples.ts b/packages/vitest/src/create/browser/examples.ts new file mode 100644 index 000000000000..f358372cdb7b --- /dev/null +++ b/packages/vitest/src/create/browser/examples.ts @@ -0,0 +1,226 @@ +import { existsSync, writeFileSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { resolve } from 'node:path' + +const jsxExample = { + name: 'HelloWorld.jsx', + js: ` +export default function HelloWorld({ name }) { + return ( +
+

Hello {name}!

+
+ ) +} +`, + ts: ` +export default function HelloWorld({ name }: { name: string }) { + return ( +
+

Hello {name}!

+
+ ) +} +`, + test: ` +import { expect, test } from 'vitest' +import { render } from '@testing-library/jsx' +import HelloWorld from './HelloWorld.jsx' + +test('renders name', () => { + const { getByText } = render() + const element = getByText('Hello Vitest!') + expect(element).toBeInTheDocument() +}) +`, +} + +const vueExample = { + name: 'HelloWorld.vue', + js: ` + + + +`, + ts: ` + + + +`, + test: ` +import { expect, test } from 'vitest' +import { render } from '@testing-library/vue' +import HelloWorld from './HelloWorld.vue' + +test('renders name', () => { + const { getByText } = render(HelloWorld, { + props: { name: 'Vitest' }, + }) + const element = getByText('Hello Vitest!') + expect(element).toBeInTheDocument() +}) +`, +} + +const svelteExample = { + name: 'HelloWorld.svelte', + js: ` + + +

Hello {name}!

+`, + ts: ` + + +

Hello {name}!

+`, + test: ` +import { expect, test } from 'vitest' +import { render } from '@testing-library/svelte' +import HelloWorld from './HelloWorld.svelte' + +test('renders name', () => { + const { getByText } = render(HelloWorld, { + props: { name: 'Vitest' }, + }) + const element = getByText('Hello Vitest!') + expect(element).toBeInTheDocument() +}) +`, +} + +const markoExample = { + name: 'HelloWorld.marko', + js: ` +class { + onCreate() { + this.state = { name: null } + } +} + +

Hello \${state.name}!

+`, + ts: ` +export interface Input { + name: string +} + +

Hello \${input.name}!

+`, + test: ` +import { expect, test } from 'vitest' +import { render } from '@marko/testing-library' +import HelloWorld from './HelloWorld.svelte' + +test('renders name', async () => { + const { getByText } = await render(HelloWorld, { name: 'Vitest' }) + const element = getByText('Hello Vitest!') + expect(element).toBeInTheDocument() +}) +`, +} + +const vanillaExample = { + name: 'HelloWorld.js', + js: ` +export default function HelloWorld({ name }) { + const parent = document.createElement('div') + document.body.appendChild(parent) + + const h1 = document.createElement('h1') + h1.textContent = 'Hello ' + name + '!' + parent.appendChild(h1) + + return parent +} +`, + ts: ` +export default function HelloWorld({ name }: { name: string }): HTMLDivElement { + const parent = document.createElement('div') + document.body.appendChild(parent) + + const h1 = document.createElement('h1') + h1.textContent = 'Hello ' + name + '!' + parent.appendChild(h1) + + return parent +} +`, + test: ` +import { expect, test } from 'vitest' +import { getByText } from '@testing-library/dom' +import HelloWorld from './HelloWorld' + +test('renders name', () => { + const parent = HelloWorld({ name: 'Vitest' }) + + const element = getByText(parent, 'Hello Vitest!') + expect(element).toBeInTheDocument() +}) +`, +} + +function getExampleTest(framework: string) { + switch (framework) { + case 'solid': + case 'preact': + case 'react': + return { + ...jsxExample, + test: jsxExample.test.replace('@testing-library/jsx', `@testing-library/${framework}`), + } + case 'vue': + return vueExample + case 'svelte': + return svelteExample + case 'marko': + return markoExample + default: + return vanillaExample + } +} + +export async function generateExampleFiles(framework: string, lang: 'ts' | 'js') { + const example = getExampleTest(framework) + let fileName = example.name + const folder = resolve(process.cwd(), 'vitest-example') + const fileContent = example[lang] + + if (!existsSync(folder)) { + await mkdir(folder, { recursive: true }) + } + const isJSX = fileName.endsWith('.jsx') + + if (isJSX && lang === 'ts') { + fileName = fileName.replace('.jsx', '.tsx') + } + else if (fileName.endsWith('.js') && lang === 'ts') { + fileName = fileName.replace('.js', '.ts') + } + + const filePath = resolve(folder, fileName) + const testPath = resolve(folder, `HelloWorld.test.${isJSX ? `${lang}x` : lang}`) + writeFileSync(filePath, fileContent.trimStart(), 'utf-8') + writeFileSync(testPath, example.test.trimStart(), 'utf-8') + return testPath +} diff --git a/packages/vitest/src/node/cli/cac.ts b/packages/vitest/src/node/cli/cac.ts index 9ef64ba1da96..9df2d566cfb8 100644 --- a/packages/vitest/src/node/cli/cac.ts +++ b/packages/vitest/src/node/cli/cac.ts @@ -178,6 +178,10 @@ export function createCLI(options: CLIOptions = {}) { benchCliOptionsConfig, ) + cli + .command('init ', undefined, options) + .action(init) + cli .command('[...filters]', undefined, options) .action((filters, options) => start('test', filters, options)) @@ -267,3 +271,13 @@ async function start(mode: VitestRunMode, cliFilters: string[], options: CliOpti process.exit(1) } } + +async function init(project: string) { + if (project !== 'browser') { + console.error(new Error('Only the "browser" project is supported. Use "vitest init browser" to create a new project.')) + process.exit(1) + } + + const { create } = await import('../../create/browser/creator') + await create() +} diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index c2fba5f8fdb1..fd81636562a2 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -29,6 +29,7 @@ export type { BrowserProviderModule, ResolvedBrowserOptions, BrowserProviderOptions, + BrowserBuiltinProvider, BrowserScript, BrowserCommand, BrowserCommandContext, diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index c1c0affa7f53..bc919f81f0bd 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -43,6 +43,8 @@ export interface BrowserProviderModule { export interface BrowserProviderOptions {} +export type BrowserBuiltinProvider = 'webdriverio' | 'playwright' | 'preview' + export interface BrowserConfigOptions { /** * if running tests in the browser should be the default @@ -61,7 +63,7 @@ export interface BrowserConfigOptions { * * @default 'preview' */ - provider?: 'webdriverio' | 'playwright' | 'preview' | (string & {}) + provider?: BrowserBuiltinProvider | (string & {}) /** * Options that are passed down to a browser provider. diff --git a/test/config/fixtures/bail/test/second.test.ts b/test/config/fixtures/bail/test/second.test.ts index 72b25f486ae5..40b4f8d6ebe2 100644 --- a/test/config/fixtures/bail/test/second.test.ts +++ b/test/config/fixtures/bail/test/second.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest' // When using multi threads/forks the first test will start before failing.test.ts fails -const isThreads = process.env.THREADS === 'true' +const isThreads = import.meta.env.THREADS === 'true' test(`1 - second.test.ts - this should ${isThreads ? 'pass' : 'be skipped'}`, async () => { await new Promise(resolve => setTimeout(resolve, 1500)) diff --git a/test/config/test/bail.test.ts b/test/config/test/bail.test.ts index b26c6a3f3efd..9a8c75e8d8f2 100644 --- a/test/config/test/bail.test.ts +++ b/test/config/test/bail.test.ts @@ -6,7 +6,24 @@ const configs: UserConfig[] = [] const pools: UserConfig[] = [{ pool: 'threads' }, { pool: 'forks' }, { pool: 'threads', poolOptions: { threads: { singleThread: true } } }] if (process.platform !== 'win32') { - pools.push({ browser: { enabled: true, name: 'chromium', provider: 'playwright' } }) + pools.push( + { + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + fileParallelism: false, + }, + }, + { + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + fileParallelism: true, + }, + }, + ) } for (const isolate of [true, false]) { @@ -20,6 +37,10 @@ for (const isolate of [true, false]) { }, forks: { isolate }, }, + browser: { + ...pool.browser!, + isolate, + }, }) } } @@ -34,6 +55,7 @@ for (const config of configs) { const isParallel = (config.pool === 'threads' && config.poolOptions?.threads?.singleThread !== true) || (config.pool === 'forks' && config.poolOptions?.forks?.singleFork !== true) + || (config.browser?.enabled && config.browser.fileParallelism) // THREADS here means that multiple tests are run parallel process.env.THREADS = isParallel ? 'true' : 'false' @@ -42,6 +64,9 @@ for (const config of configs) { root: './fixtures/bail', bail: 1, ...config, + env: { + THREADS: process.env.THREADS, + }, }) expect(exitCode).toBe(1) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index fb3e40a55dd7..b1e212139237 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -9,7 +9,17 @@ import { type Options, execa } from 'execa' import { dirname, resolve } from 'pathe' import { Cli } from './cli' -export async function runVitest(config: UserConfig, cliFilters: string[] = [], mode: VitestRunMode = 'test', viteOverrides: ViteUserConfig = {}) { +interface VitestRunnerCLIOptions { + std?: 'inherit' +} + +export async function runVitest( + config: UserConfig, + cliFilters: string[] = [], + mode: VitestRunMode = 'test', + viteOverrides: ViteUserConfig = {}, + runnerOptions: VitestRunnerCLIOptions = {}, +) { // Reset possible previous runs process.exitCode = 0 let exitCode = process.exitCode @@ -18,8 +28,22 @@ export async function runVitest(config: UserConfig, cliFilters: string[] = [], m const exit = process.exit process.exit = (() => { }) as never - const stdout = new Writable({ write: (_, __, callback) => callback() }) - const stderr = new Writable({ write: (_, __, callback) => callback() }) + const stdout = new Writable({ + write(chunk, __, callback) { + if (runnerOptions.std === 'inherit') { + process.stdout.write(chunk.toString()) + } + callback() + }, + }) + const stderr = new Writable({ + write(chunk, __, callback) { + if (runnerOptions.std === 'inherit') { + process.stderr.write(chunk.toString()) + } + callback() + }, + }) // "node:tty".ReadStream doesn't work on Github Windows CI, let's simulate it const stdin = new Readable({ read: () => '' }) as NodeJS.ReadStream