Skip to content

Commit 154cb22

Browse files
authored
feat(browser): add an option to take screenshots if the browser test fails (#5975)
1 parent 14a217d commit 154cb22

File tree

18 files changed

+172
-31
lines changed

18 files changed

+172
-31
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ docs/public/sponsors
2222
.eslintcache
2323
docs/.vitepress/cache/
2424
!test/cli/fixtures/dotted-files/**/.cache
25-
test/browser/test/__screenshots__/**/*
25+
test/**/__screenshots__/**/*
2626
test/browser/fixtures/update-snapshot/basic.test.ts
2727
.vitest-reports

docs/config/index.md

+14
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,20 @@ Should Vitest UI be injected into the page. By default, injects UI iframe during
16201620

16211621
Default iframe's viewport.
16221622

1623+
#### browser.screenshotDirectory {#browser-screenshotdirectory}
1624+
1625+
- **Type:** `string`
1626+
- **Default:** `__snapshots__` in the test file directory
1627+
1628+
Path to the snapshots directory relative to the `root`.
1629+
1630+
#### browser.screenshotFailures {#browser-screenshotfailures}
1631+
1632+
- **Type:** `boolean`
1633+
- **Default:** `!browser.ui`
1634+
1635+
Should Vitest take screenshots if the test fails.
1636+
16231637
#### browser.orchestratorScripts {#browser-orchestratorscripts}
16241638

16251639
- **Type:** `BrowserScript[]`

packages/browser/src/client/tester/mocker.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class VitestBrowserClientMocker {
6464
const actualUrl = `${url.pathname}${
6565
url.search ? `${url.search}&${query}` : `?${query}`
6666
}${url.hash}`
67-
return getBrowserState().wrapModule(() => import(actualUrl))
67+
return getBrowserState().wrapModule(() => import(/* @vite-ignore */ actualUrl))
6868
}
6969

7070
public async importMock(rawId: string, importer: string) {
@@ -86,11 +86,11 @@ export class VitestBrowserClientMocker {
8686

8787
if (type === 'redirect') {
8888
const url = new URL(`/@id/${mockPath}`, location.href)
89-
return import(url.toString())
89+
return import(/* @vite-ignore */ url.toString())
9090
}
9191
const url = new URL(`/@id/${resolvedId}`, location.href)
9292
const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}`
93-
const moduleObject = await import(`${url.pathname}${query}${url.hash}`)
93+
const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}${url.hash}`)
9494
return this.mockObject(moduleObject)
9595
}
9696

packages/browser/src/client/tester/runner.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { VitestExecutor } from 'vitest/execute'
44
import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners'
55
import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser'
66
import { TraceMap, originalPositionFor } from 'vitest/utils'
7-
import { importId } from '../utils'
7+
import { page } from '@vitest/browser/context'
8+
import { importFs, importId } from '../utils'
89
import { globalChannel } from '../channel'
910
import { VitestBrowserSnapshotEnvironment } from './snapshot'
1011
import { rpc } from './rpc'
@@ -53,6 +54,12 @@ export function createBrowserRunner(
5354
}
5455
}
5556

57+
onTaskFinished = async (task: Task) => {
58+
if (this.config.browser.screenshotFailures && task.result?.state === 'fail') {
59+
await page.screenshot()
60+
}
61+
}
62+
5663
onCancel = (reason: CancelReason) => {
5764
super.onCancel?.(reason)
5865
globalChannel.postMessage({ type: 'cancel', reason })
@@ -123,7 +130,7 @@ export function createBrowserRunner(
123130
const prefix = `/${/^\w:/.test(filepath) ? '@fs/' : ''}`
124131
const query = `${test ? 'browserv' : 'v'}=${hash}`
125132
const importpath = `${prefix}${filepath}?${query}`.replace(/\/+/g, '/')
126-
await import(importpath)
133+
await import(/* @vite-ignore */ importpath)
127134
}
128135
}
129136
}
@@ -140,17 +147,25 @@ export async function initiateRunner(
140147
}
141148
const runnerClass
142149
= config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner
150+
151+
const executeId = (id: string) => {
152+
if (id[0] === '/' || id[1] === ':') {
153+
return importFs(id)
154+
}
155+
return importId(id)
156+
}
157+
143158
const BrowserRunner = createBrowserRunner(runnerClass, mocker, state, {
144159
takeCoverage: () =>
145-
takeCoverageInsideWorker(config.coverage, { executeId: importId }),
160+
takeCoverageInsideWorker(config.coverage, { executeId }),
146161
})
147162
if (!config.snapshotOptions.snapshotEnvironment) {
148163
config.snapshotOptions.snapshotEnvironment = new VitestBrowserSnapshotEnvironment()
149164
}
150165
const runner = new BrowserRunner({
151166
config,
152167
})
153-
const executor = { executeId: importId } as VitestExecutor
168+
const executor = { executeId } as VitestExecutor
154169
const [diffOptions] = await Promise.all([
155170
loadDiffConfig(config, executor),
156171
loadSnapshotSerializers(config, executor),

packages/browser/src/client/tester/tester.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ async function runTests(files: string[]) {
9292
try {
9393
preparedData = await prepareTestEnvironment(files)
9494
}
95-
catch (error) {
96-
debug('data cannot be loaded because it threw an error')
95+
catch (error: any) {
96+
debug('runner cannot be loaded because it threw an error', error.stack || error.message)
9797
await client.rpc.onUnhandledError(serializeError(error), 'Preload Error')
9898
done(files)
9999
return

packages/browser/src/client/utils.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import type { ResolvedConfig, WorkerGlobalState } from 'vitest'
22

33
export async function importId(id: string) {
4-
const name = `/@id/${id}`
5-
return getBrowserState().wrapModule(() => import(name))
4+
const name = `/@id/${id}`.replace(/\\/g, '/')
5+
return getBrowserState().wrapModule(() => import(/* @vite-ignore */ name))
6+
}
7+
8+
export async function importFs(id: string) {
9+
const name = `/@fs/${id}`.replace(/\\/g, '/')
10+
return getBrowserState().wrapModule(() => import(/* @vite-ignore */ name))
611
}
712

813
export function getConfig(): ResolvedConfig {

packages/browser/src/client/vite.config.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export default defineConfig({
88
server: {
99
watch: { ignored: ['**/**'] },
1010
},
11+
esbuild: {
12+
legalComments: 'inline',
13+
},
1114
build: {
1215
minify: false,
1316
outDir: '../../dist/client',
@@ -19,7 +22,7 @@ export default defineConfig({
1922
orchestrator: resolve(__dirname, './orchestrator.html'),
2023
tester: resolve(__dirname, './tester/tester.html'),
2124
},
22-
external: [/__virtual_vitest__/],
25+
external: [/__virtual_vitest__/, '@vitest/browser/context'],
2326
},
2427
},
2528
plugins: [

packages/browser/src/node/esmInjector.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ export function injectDynamicImport(
2626
// s.update(node.start, node.end, viImportMetaKey)
2727
},
2828
onDynamicImport(node) {
29-
const replace = '__vitest_browser_runner__.wrapModule(() => import('
29+
const replaceString = '__vitest_browser_runner__.wrapModule(() => import('
30+
const importSubstring = code.substring(node.start, node.end)
31+
const hasIgnore = importSubstring.includes('/* @vite-ignore */')
3032
s.overwrite(
3133
node.start,
3234
(node.source as Positioned<Expression>).start,
33-
replace,
35+
replaceString + (hasIgnore ? '/* @vite-ignore */ ' : ''),
3436
)
3537
s.overwrite(node.end - 1, node.end, '))')
3638
},

packages/browser/src/node/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export async function createBrowserServer(
2525
const vite = await createServer({
2626
...project.options, // spread project config inlined in root workspace config
2727
base: '/',
28-
logLevel: 'error',
28+
logLevel: (process.env.VITEST_BROWSER_DEBUG as 'info') ?? 'info',
2929
mode: project.config.mode,
3030
configFile: configPath,
3131
// watch is handled by Vitest

packages/browser/src/node/plugin.ts

+58-13
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,44 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
122122
define[`import.meta.env.${env}`] = stringValue
123123
}
124124

125+
const entries: string[] = [
126+
...browserTestFiles,
127+
...setupFiles,
128+
resolve(vitestDist, 'index.js'),
129+
resolve(vitestDist, 'browser.js'),
130+
resolve(vitestDist, 'runners.js'),
131+
resolve(vitestDist, 'utils.js'),
132+
...(project.config.snapshotSerializers || []),
133+
]
134+
135+
if (project.config.diff) {
136+
entries.push(project.config.diff)
137+
}
138+
139+
if (project.ctx.coverageProvider) {
140+
const coverage = project.ctx.config.coverage
141+
const provider = coverage.provider
142+
if (provider === 'v8') {
143+
const path = tryResolve('@vitest/coverage-v8', [project.ctx.config.root])
144+
if (path) {
145+
entries.push(path)
146+
}
147+
}
148+
else if (provider === 'istanbul') {
149+
const path = tryResolve('@vitest/coverage-istanbul', [project.ctx.config.root])
150+
if (path) {
151+
entries.push(path)
152+
}
153+
}
154+
else if (provider === 'custom' && coverage.customProviderModule) {
155+
entries.push(coverage.customProviderModule)
156+
}
157+
}
158+
125159
return {
126160
define,
127161
optimizeDeps: {
128-
entries: [
129-
...browserTestFiles,
130-
...setupFiles,
131-
resolve(vitestDist, 'index.js'),
132-
resolve(vitestDist, 'browser.js'),
133-
resolve(vitestDist, 'runners.js'),
134-
resolve(vitestDist, 'utils.js'),
135-
],
162+
entries,
136163
exclude: [
137164
'vitest',
138165
'vitest/utils',
@@ -163,6 +190,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
163190
'vitest > chai > loupe',
164191
'vitest > @vitest/runner > p-limit',
165192
'vitest > @vitest/utils > diff-sequences',
193+
'vitest > @vitest/utils > loupe',
166194
'@vitest/browser > @testing-library/user-event',
167195
'@vitest/browser > @testing-library/dom',
168196
],
@@ -235,10 +263,9 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
235263
enforce: 'post',
236264
async config(viteConfig) {
237265
// Enables using ignore hint for coverage providers with @preserve keyword
238-
if (viteConfig.esbuild !== false) {
239-
viteConfig.esbuild ||= {}
240-
viteConfig.esbuild.legalComments = 'inline'
241-
}
266+
viteConfig.esbuild ||= {}
267+
viteConfig.esbuild.legalComments = 'inline'
268+
242269
const server = resolveApiServerConfig(
243270
viteConfig.test?.browser || {},
244271
defaultBrowserPort,
@@ -294,8 +321,8 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
294321
{
295322
name: 'test-utils-rewrite',
296323
setup(build) {
297-
const _require = createRequire(import.meta.url)
298324
build.onResolve({ filter: /@vue\/test-utils/ }, (args) => {
325+
const _require = getRequire()
299326
// resolve to CJS instead of the browser because the browser version expects a global Vue object
300327
const resolved = _require.resolve(args.path, {
301328
paths: [args.importer],
@@ -313,6 +340,24 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
313340
]
314341
}
315342

343+
function tryResolve(path: string, paths: string[]) {
344+
try {
345+
const _require = getRequire()
346+
return _require.resolve(path, { paths })
347+
}
348+
catch {
349+
return undefined
350+
}
351+
}
352+
353+
let _require: NodeRequire
354+
function getRequire() {
355+
if (!_require) {
356+
_require = createRequire(import.meta.url)
357+
}
358+
return _require
359+
}
360+
316361
function resolveCoverageFolder(project: WorkspaceProject) {
317362
const options = project.ctx.config
318363
const htmlReporter = options.coverage?.enabled

packages/browser/src/node/providers/playwright.ts

+13
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
141141
const page = await context.newPage()
142142
this.pages.set(contextId, page)
143143

144+
if (process.env.VITEST_PW_DEBUG) {
145+
page.on('requestfailed', (request) => {
146+
console.error(
147+
'[PW Error]',
148+
request.resourceType(),
149+
'request failed for',
150+
request.url(),
151+
'url:',
152+
request.failure()?.errorText,
153+
)
154+
})
155+
}
156+
144157
return page
145158
}
146159

packages/runner/src/run.ts

+7
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,13 @@ export async function runTest(test: Test | Custom, runner: VitestRunner) {
262262
return
263263
}
264264

265+
try {
266+
await runner.onTaskFinished?.(test)
267+
}
268+
catch (e) {
269+
failTask(test.result, e, runner.config.diffOptions)
270+
}
271+
265272
try {
266273
await callSuiteHook(suite, test, 'afterEach', runner, [
267274
test.context,

packages/runner/src/types/runner.ts

+4
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export interface VitestRunner {
8080
test: Task,
8181
options: { retry: number; repeats: number }
8282
) => unknown
83+
/**
84+
* When the task has finished running, but before cleanup hooks are called
85+
*/
86+
onTaskFinished?: (test: Task) => unknown
8387
/**
8488
* Called after result and state are set.
8589
*/

packages/utils/src/source-map.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ const stackIgnorePatterns = [
3232
'/node_modules/tinypool/',
3333
'/node_modules/tinyspy/',
3434
// browser related deps
35-
'/deps/',
35+
'/deps/chunk-',
36+
'/deps/@vitest',
37+
'/deps/loupe',
38+
'/deps/chai',
3639
/node:\w+/,
3740
/__vitest_test__/,
3841
/__vitest_browser__/,

packages/vitest/src/node/cli/cli-config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
413413
commands: null,
414414
viewport: null,
415415
screenshotDirectory: null,
416+
screenshotFailures: null,
416417
},
417418
},
418419
pool: {

packages/vitest/src/node/config.ts

+21
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,13 @@ export function resolveConfig(
235235
}
236236
}
237237

238+
if (resolved.coverage.enabled && resolved.coverage.provider === 'custom' && resolved.coverage.customProviderModule) {
239+
resolved.coverage.customProviderModule = resolvePath(
240+
resolved.coverage.customProviderModule,
241+
resolved.root,
242+
)
243+
}
244+
238245
resolved.expect ??= {}
239246

240247
resolved.deps ??= {}
@@ -683,6 +690,20 @@ export function resolveConfig(
683690
resolved.browser.screenshotDirectory,
684691
)
685692
}
693+
const isPreview = resolved.browser.provider === 'preview'
694+
if (isPreview && resolved.browser.screenshotFailures === true) {
695+
console.warn(c.yellow(
696+
[
697+
`Browser provider "preview" doesn't support screenshots, `,
698+
`so "browser.screenshotFailures" option is forcefully disabled. `,
699+
`Set "browser.screenshotFailures" to false or remove it from the config to suppress this warning.`,
700+
].join(''),
701+
))
702+
resolved.browser.screenshotFailures = false
703+
}
704+
else {
705+
resolved.browser.screenshotFailures ??= !isPreview && !resolved.browser.ui
706+
}
686707

687708
resolved.browser.viewport ??= {} as any
688709
resolved.browser.viewport.width ??= 414

0 commit comments

Comments
 (0)