Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,10 @@ export default ({ mode }: { mode: string }) => {
text: 'browser.screenshotFailures',
link: '/config/browser/screenshotfailures',
},
{
text: 'browser.dependencySourcemaps',
link: '/config/browser/dependencysourcemaps',
},
{
text: 'browser.orchestratorScripts',
link: '/config/browser/orchestratorscripts',
Expand Down
21 changes: 21 additions & 0 deletions docs/config/browser/dependencysourcemaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: browser.dependencySourcemaps | Config
outline: deep
---

# browser.dependencySourcemaps

- **Type:** `boolean`
- **Default:** `true`

Serve sourcemaps of your dependencies (files in `node_modules`) to the browser during headless test runs.

These sourcemaps are used by browser devtools: with `dependencySourcemaps: false`, pausing inside dependency code shows the compiled code the browser actually runs instead of the dependency's original sources. If you don't debug into your dependencies this way, disabling them makes test runs faster: the server doesn't generate and inline the maps, and every browser tab downloads several times fewer bytes.

Reported test errors are not affected: when an error is thrown inside a pre-bundled dependency, Vitest maps its stack frames using the sourcemaps stored on disk even when this option is disabled. Frames from dependencies that are served without pre-bundling (for example, [linked packages](https://vite.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies)) that don't ship their own sourcemaps fall back to the position in the served code, which usually matches the original file.

Vitest never serves sourcemaps of its own pre-built modules in headless runs (unless [`--inspect`](/guide/cli#inspect) is used) — their frames are hidden from stack traces anyway. Sourcemaps of your own source files are always served.

::: tip
If some of your workspace code resolves to a `node_modules` path (for example, with `resolve.preserveSymlinks`), set [`server.sourcemapIgnoreList`](https://vite.dev/config/server-options#server-sourcemapignorelist) to keep its sourcemaps even when this option is disabled.
:::
7 changes: 7 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,13 @@ Should browser test files run in parallel. Use `--browser.fileParallelism=false`

If connection to the browser takes longer, the test suite will fail (default: `60_000`)

### browser.dependencySourcemaps

- **CLI:** `--browser.dependencySourcemaps`
- **Config:** [browser.dependencySourcemaps](/config/browser/dependencysourcemaps)

Serve sourcemaps of dependencies to the browser in headless runs, used by devtools when debugging into `node_modules`. Reported test errors are source-mapped either way. Use `--browser.dependencySourcemaps=false` to speed up test runs if you don't step into dependency code (default: `true`)

### browser.trackUnhandledErrors

- **CLI:** `--browser.trackUnhandledErrors`
Expand Down
148 changes: 124 additions & 24 deletions packages/browser-playwright/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,116 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv
name: 'playwright',
supportedBrowser: playwrightBrowsers,
options,
prewarm(project) {
prewarmBrowser(project, options)
},
providerFactory(project) {
return new PlaywrightBrowserProvider(project, options)
},
})
}

interface WarmBrowser {
promise: Promise<Browser>
launchOptionsJson: string
}

// keyed by the provider options object (shared between the factory and every
// provider instance created from it), then by browser name
const warmBrowsers = new WeakMap<object, Map<string, WarmBrowser>>()

// starts importing playwright and launching the browser while the node side
// is still creating the vite server, so the launch latency overlaps it. The
// launch options are resolved by the same code as the real launch — if they
// still differ by the time the provider opens the browser, the warm instance
// is discarded, so this is always safe
function prewarmBrowser(project: TestProject, options: PlaywrightProviderOptions): void {
if (
options.connectOptions
|| options.persistentContext
// don't speculate on debugging flows
|| project.vitest.config.inspector.enabled
) {
return
}
const browserName = project.config.browser.name
if (!(playwrightBrowsers as readonly string[]).includes(browserName)) {
return
}
let byName = warmBrowsers.get(options)
if (!byName) {
byName = new Map()
warmBrowsers.set(options, byName)
}
if (byName.has(browserName)) {
return
}
const launchOptions = resolveLaunchOptions(project, options)
const entry: WarmBrowser = {
launchOptionsJson: JSON.stringify(launchOptions),
promise: (async () => {
debug?.('[%s] prewarming the browser', browserName)
const playwright = await import('playwright')
return playwright[browserName as PlaywrightBrowser].launch(launchOptions)
})(),
}
// if the warm launch fails, drop it so the real launch retries
// and surfaces the error through the normal path
entry.promise.catch(() => {
if (byName.get(browserName) === entry) {
byName.delete(browserName)
}
})
byName.set(browserName, entry)
}

function takeWarmBrowser(options: PlaywrightProviderOptions, browserName: string): WarmBrowser | undefined {
const byName = warmBrowsers.get(options)
const warm = byName?.get(browserName)
if (warm) {
byName!.delete(browserName)
}
return warm
}

function resolveLaunchOptions(
project: TestProject,
providerOptions: PlaywrightProviderOptions,
): LaunchOptions {
const options = project.config.browser
const browserName = options.name

const launchOptions: LaunchOptions = {
...providerOptions.launchOptions,
headless: options.headless,
}

if (typeof options.trace === 'object' && options.trace.tracesDir) {
launchOptions.tracesDir = options.trace?.tracesDir
}

const inspector = project.vitest.config.inspector
if (inspector.enabled) {
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
const port = inspector.port || 9229

launchOptions.args ||= []
launchOptions.args.push(`--remote-debugging-port=${port}`)
}

// start Vitest UI maximized only on supported browsers
if (options.ui && browserName === 'chromium') {
if (!launchOptions.args) {
launchOptions.args = []
}
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
launchOptions.args.push('--start-maximized')
}
}

return launchOptions
}

export class PlaywrightBrowserProvider implements BrowserProvider {
public name = 'playwright' as const
public supportsParallelism = true
Expand Down Expand Up @@ -167,44 +271,21 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}

this.browserPromise = (async () => {
const options = this.project.config.browser

const playwright = await import('playwright')

const launchOptions: LaunchOptions = {
...this.options.launchOptions,
headless: options.headless,
}

if (typeof options.trace === 'object' && options.trace.tracesDir) {
launchOptions.tracesDir = options.trace?.tracesDir
}
const launchOptions = resolveLaunchOptions(this.project, this.options)

const inspector = this.project.vitest.config.inspector
if (inspector.enabled) {
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
const port = inspector.port || 9229
const host = inspector.host || '127.0.0.1'

launchOptions.args ||= []
launchOptions.args.push(`--remote-debugging-port=${port}`)

if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
this.project.vitest.logger.warn(`Custom inspector host "${host}" will be ignored. Chromium only allows remote debugging on localhost.`)
}
this.project.vitest.logger.log(`Debugger listening on ws://127.0.0.1:${port}`)
}

// start Vitest UI maximized only on supported browsers
if (this.project.config.browser.ui && this.browserName === 'chromium') {
if (!launchOptions.args) {
launchOptions.args = []
}
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
launchOptions.args.push('--start-maximized')
}
}

debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions)

if (this.options.connectOptions) {
Expand Down Expand Up @@ -250,6 +331,20 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.browser = this.persistentContext.browser()!
}
else {
const warm = takeWarmBrowser(this.options, this.browserName)
if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) {
const browser = await warm.promise.catch(() => null)
if (browser?.isConnected()) {
debug?.('[%s] adopting the prewarmed browser', this.browserName)
this.browser = browser
this.browserPromise = null
return this.browser
}
}
else if (warm) {
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
void warm.promise.then(browser => browser.close()).catch(() => {})
}
this.browser = await playwright[this.browserName].launch(launchOptions)
}
this.browserPromise = null
Expand Down Expand Up @@ -553,6 +648,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {

debug?.('[%s] closing provider', this.browserName)
this.closing = true
// a prewarmed browser that was never adopted must not outlive the provider
const warm = takeWarmBrowser(this.options, this.browserName)
if (warm) {
void warm.promise.then(browser => browser.close()).catch(() => {})
}
if (this.browserPromise) {
await this.browserPromise
this.browserPromise = null
Expand Down
16 changes: 16 additions & 0 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,8 @@ function generateFileId(file: string) {
)
}

let currentViewport: { width: number; height: number } | undefined

async function setIframeViewport(
width: number,
height: number,
Expand All @@ -589,12 +591,26 @@ async function setIframeViewport(
document.body.style.setProperty('--viewport-width', `${width}px`)
document.body.style.setProperty('--viewport-height', `${height}px`)

// playwright emulates the viewport per page, so it keeps its size
// between test files and the command round trip is only needed when
// the size actually changes; other providers resize the window, which
// outside code can move under us, so they always re-pin
const cacheable = getBrowserState().provider === 'playwright'
if (
cacheable
&& currentViewport?.width === width
&& currentViewport?.height === height
) {
return
}

await client.rpc.triggerCommand(
getBrowserState().sessionId,
'__vitest_viewport',
undefined,
[{ width, height }],
)
currentViewport = cacheable ? { width, height } : undefined
}
}

Expand Down
18 changes: 17 additions & 1 deletion packages/browser/src/client/tester/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function createBrowserRunner(
public config: SerializedConfig
public hashMap = browserHashMap
public sourceMapCache = new Map<string, any>()
private sourceMapPrefetches = new Map<string, Promise<any>>()
public method = 'run' as TestExecutionMethod
private commands: CommandsManager

Expand Down Expand Up @@ -218,7 +219,13 @@ export function createBrowserRunner(
if (!('filepath' in suite)) {
return
}
const map = await rpc().getBrowserFileSourceMap(suite.filepath)
// usually resolved already: the request is fired as soon as the
// file finishes importing, while collection is still running
const map = await (
this.sourceMapPrefetches.get(suite.filepath)
?? rpc().getBrowserFileSourceMap(suite.filepath)
)
this.sourceMapPrefetches.delete(suite.filepath)
this.sourceMapCache.set(suite.filepath, map)
const snapshotEnvironment = this.config.snapshotOptions.snapshotEnvironment
if (snapshotEnvironment instanceof VitestBrowserSnapshotEnvironment) {
Expand Down Expand Up @@ -334,6 +341,15 @@ export function createBrowserRunner(
catch (err) {
throw new Error(`Failed to import test file ${filepath}`, { cause: err })
}

if (mode === 'collect' && !this.sourceMapPrefetches.has(filepath)) {
// the file is transformed now, so the server can hand out its map;
// request it early so onBeforeRunSuite doesn't have to wait
this.sourceMapPrefetches.set(
filepath,
rpc().getBrowserFileSourceMap(filepath).catch(() => undefined),
)
}
}

trace = <T>(name: string, attributes: Record<string, any> | (() => T), cb?: () => T): T => {
Expand Down
Loading
Loading