Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3052f49
chore: repro-6945
hi-ogawa Feb 10, 2026
b358c39
chore: 🚨
hi-ogawa Feb 10, 2026
ca6cfa8
chore: cleanup
hi-ogawa Feb 10, 2026
e0b7001
feat: add coverage.htmlDir config
hi-ogawa Feb 10, 2026
f030606
refactor: use coverage.htmlDir and standardize on /coverage path
hi-ogawa Feb 10, 2026
c7d88b6
Merge branch 'main' into 02-10-chore_repro-6945
hi-ogawa Feb 10, 2026
890b981
chore: todo
hi-ogawa Feb 11, 2026
a4ede98
chore: todo
hi-ogawa Feb 11, 2026
9e08747
feat: use relative ./coverage
hi-ogawa Feb 11, 2026
22aabd6
fix: add HTMLReporter.onFinishedReportCoverage
hi-ogawa Feb 11, 2026
6cdb01a
refactor: hard-code ./coverage/index.html in ui
hi-ogawa Feb 11, 2026
9a36af6
refactor: remove stale SerializedCoverageConfig.htmlReporeter
hi-ogawa Feb 11, 2026
eb4d205
chore: comment
hi-ogawa Feb 11, 2026
c9c1298
test: test htmlDir default
hi-ogawa Feb 11, 2026
11584d4
fix: support lcov
hi-ogawa Feb 11, 2026
751ab17
fix: fix resolve
hi-ogawa Feb 11, 2026
242ffb4
test: deafult should work
hi-ogawa Feb 11, 2026
9e9550f
chore: browser ui not working?
hi-ogawa Feb 11, 2026
b1d2eec
fix: browser coverage ui in orchestrator
hi-ogawa Feb 11, 2026
07ca0c7
docs: update coverage docs for new htmlDir option and UI integration
hi-ogawa Feb 11, 2026
c338b2a
chore: remove examples/repro-6945
hi-ogawa Feb 11, 2026
ef74e62
fix: use relative path for attachment URLs to support subpath deployment
hi-ogawa Feb 11, 2026
b668169
test: test html reporter with custom base deployment
hi-ogawa Feb 11, 2026
5ad3a71
Merge branch 'main' into 02-10-chore_repro-6945
hi-ogawa Feb 11, 2026
5859bd4
fix: support html-spa
hi-ogawa Feb 12, 2026
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
12 changes: 10 additions & 2 deletions docs/config/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,6 @@ Vitest will delete this directory before running tests if `coverage.clean` is en

Directory to write coverage report to.

To preview the coverage report in the output of [HTML reporter](/guide/reporters.html#html-reporter), this option must be set as a sub-directory of the html report directory (for example `./html/coverage`).

## coverage.reporter

- **Type:** `string | string[] | [string, {}][]`
Expand Down Expand Up @@ -395,3 +393,13 @@ Concurrency limit used when processing the coverage results.
- **CLI:** `--coverage.customProviderModule=<path or module name>`

Specifies the module name or path for the custom coverage provider module. See [Guide - Custom Coverage Provider](/guide/coverage#custom-coverage-provider) for more information.

## coverage.htmlDir

- **Type:** `string`
- **Default:** Automatically inferred from `html`, `html-spa`, or `lcov` coverage reporters
- **CLI:** `--coverage.htmlDir=<path>`

Directory of HTML coverage output to be served in [Vitest UI](/guide/ui) and [HTML reporter](/guide/reporters.html#html-reporter).

This is automatically configured when using builtin coverage reporters that produce HTML output (`html`, `html-spa`, and `lcov`). Use this option to override with a custom coverage reporting location when using custom coverage reporters.
6 changes: 3 additions & 3 deletions docs/guide/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,9 @@ If code coverage generation is slow on your project, see [Profiling Test Perform

You can check your coverage report in [Vitest UI](/guide/ui).

Vitest UI will enable coverage report when it is enabled explicitly and the html coverage reporter is present, otherwise it will not be available:
- enable `coverage.enabled=true` in your configuration file or run Vitest with `--coverage.enabled=true` flag
- add `html` to the `coverage.reporter` list: you can also enable `subdir` option to put coverage report in a subdirectory
Since the default `coverage.reporter` includes `html`, coverage report is available in Vitest UI when `coverage.enabled` is set to `true` in your configuration file or when running Vitest with `--coverage.enabled=true` flag.

The [`coverage.htmlDir`](/config/coverage#coverage-htmldir) option is automatically inferred from coverage reporters that produce HTML output (`html`, `html-spa`, and `lcov`). You can override it to point to a custom HTML coverage output directory.

<img alt="html coverage activation in Vitest UI" img-light src="/vitest-ui-show-coverage-light.png">
<img alt="html coverage activation in Vitest UI" img-dark src="/vitest-ui-show-coverage-dark.png">
Expand Down
57 changes: 6 additions & 51 deletions packages/browser/src/node/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import type { HtmlTagDescriptor } from 'vite'
import type { Plugin } from 'vitest/config'
import type { Vitest } from 'vitest/node'
import type { ParentBrowserProject } from './projectParent'
import { createReadStream, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dynamicImportPlugin } from '@vitest/mocker/node'
import { toArray } from '@vitest/utils/helpers'
import MagicString from 'magic-string'
import { basename, dirname, join, resolve } from 'pathe'
import { dirname, join, resolve } from 'pathe'
import sirv from 'sirv'
import { coverageConfigDefaults } from 'vitest/config'
import {
isFileServingAllowed,
isValidApiRequest,
Expand Down Expand Up @@ -63,18 +61,12 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
},
)

const coverageFolder = resolveCoverageFolder(parentServer.vitest)
const coveragePath = coverageFolder ? coverageFolder[1] : undefined
if (coveragePath && base === coveragePath) {
throw new Error(
`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`,
)
}

if (coverageFolder) {
// Serve coverage HTML at ./coverage if configured
const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir
if (coverageHtmlDir) {
server.middlewares.use(
coveragePath!,
sirv(coverageFolder[0], {
'/__vitest_test__/coverage',
sirv(coverageHtmlDir, {
single: true,
dev: true,
setHeaders: (res) => {
Expand Down Expand Up @@ -604,43 +596,6 @@ function getRequire() {
return _require
}

function resolveCoverageFolder(vitest: Vitest) {
const options = vitest.config
const coverageOptions = vitest._coverageOptions
const htmlReporter = coverageOptions?.enabled
? toArray(options.coverage.reporter).find((reporter) => {
if (typeof reporter === 'string') {
return reporter === 'html'
}

return reporter[0] === 'html'
})
: undefined

if (!htmlReporter) {
return undefined
}

// reportsDirectory not resolved yet
const root = resolve(
options.root || process.cwd(),
coverageOptions.reportsDirectory || coverageConfigDefaults.reportsDirectory,
)

const subdir
= Array.isArray(htmlReporter)
&& htmlReporter.length > 1
&& 'subdir' in htmlReporter[1]
? htmlReporter[1].subdir
: undefined

if (!subdir || typeof subdir !== 'string') {
return [root, `/${basename(root)}/`]
}

return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
}

const postfixRE = /[?#].*$/
function cleanUrl(url: string): string {
return url.replace(postfixRE, '')
Expand Down
6 changes: 1 addition & 5 deletions packages/ui/client/components/Coverage.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
<script setup lang="ts">
import DetailsHeaderButtons from '~/components/DetailsHeaderButtons.vue'
import { browserState } from '~/composables/client'
defineProps<{
src: string
}>()
</script>

<template>
Expand All @@ -15,7 +11,7 @@ defineProps<{
<DetailsHeaderButtons v-if="browserState" />
</div>
<div flex-auto py-1 bg-white>
<iframe id="vitest-ui-coverage" :src="src" />
<iframe id="vitest-ui-coverage" src="./coverage/index.html" />
</div>
</div>
</template>
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/client/composables/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isReport } from '~/constants'
export function getAttachmentUrl(attachment: TestAttachment): string {
// html reporter always saves files into /data/ folder
if (isReport) {
return `/data/${attachment.path}`
return `./data/${attachment.path}`
}
const contentType = attachment.contentType ?? 'application/octet-stream'
if (attachment.path) {
Expand Down
19 changes: 1 addition & 18 deletions packages/ui/client/composables/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const coverageConfigured = computed(() => coverage.value?.enabled)
export const coverageEnabled = computed(() => {
return (
coverageConfigured.value
&& !!coverage.value?.htmlReporter
&& !!coverage.value?.htmlDir
)
})
export const mainSizes = useLocalStorage<[left: number, right: number]>(
Expand Down Expand Up @@ -71,23 +71,6 @@ export const panels = reactive({
},
})

// TODO
// For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
// Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.
export const coverageUrl = computed(() => {
if (coverageEnabled.value) {
const idx = coverage.value!.reportsDirectory.lastIndexOf('/')
const htmlReporterSubdir = coverage.value!.htmlReporter?.subdir
return htmlReporterSubdir
? `/${coverage.value!.reportsDirectory.slice(idx + 1)}/${
htmlReporterSubdir
}/index.html`
: `/${coverage.value!.reportsDirectory.slice(idx + 1)}/index.html`
}

return undefined
})

watch(
testRunState,
(state) => {
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/client/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import Navigation from '~/components/Navigation.vue'
import ProgressBar from '~/components/ProgressBar.vue'
import { browserState } from '~/composables/client'
import {
coverageUrl,
coverageVisible,
detailSizes,
detailsPanelVisible,
Expand Down Expand Up @@ -97,7 +96,6 @@ function allowBrowserEvents() {
<Coverage
v-else-if="coverageVisible"
key="coverage"
:src="coverageUrl!"
/>
<FileDetails v-else key="details" />
</transition>
Expand Down Expand Up @@ -127,7 +125,6 @@ function allowBrowserEvents() {
<Coverage
v-else-if="coverageVisible"
key="coverage"
:src="coverageUrl!"
/>
<FileDetails v-else key="details" />
</div>
Expand Down
56 changes: 6 additions & 50 deletions packages/ui/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import type { Plugin } from 'vite'
import type { Vitest } from 'vitest/node'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { toArray } from '@vitest/utils/helpers'
import { basename, resolve } from 'pathe'
import { join, resolve } from 'pathe'
import sirv from 'sirv'
import c from 'tinyrainbow'
import { coverageConfigDefaults } from 'vitest/config'
import { isFileServingAllowed, isValidApiRequest } from 'vitest/node'
import { version } from '../package.json'

Expand All @@ -29,18 +27,13 @@ export default (ctx: Vitest): Plugin => {
handler(server) {
const uiOptions = ctx.config
const base = uiOptions.uiBase
const coverageFolder = resolveCoverageFolder(ctx)
const coveragePath = coverageFolder ? coverageFolder[1] : undefined
if (coveragePath && base === coveragePath) {
throw new Error(
`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`,
)
}

if (coverageFolder) {
// Serve coverage HTML at ./coverage if configured
const coverageHtmlDir = ctx.config.coverage?.htmlDir
if (coverageHtmlDir) {
server.middlewares.use(
coveragePath!,
sirv(coverageFolder[0], {
join(base, 'coverage'),
sirv(coverageHtmlDir, {
single: true,
dev: true,
setHeaders: (res) => {
Expand Down Expand Up @@ -126,40 +119,3 @@ export default (ctx: Vitest): Plugin => {
},
}
}

function resolveCoverageFolder(ctx: Vitest) {
const options = ctx.config
const htmlReporter
= options.api?.port && options.coverage?.enabled
? toArray(options.coverage.reporter).find((reporter) => {
if (typeof reporter === 'string') {
return reporter === 'html'
}

return reporter[0] === 'html'
})
: undefined

if (!htmlReporter) {
return undefined
}

// reportsDirectory not resolved yet
const root = resolve(
ctx.config?.root || options.root || process.cwd(),
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
)

const subdir
= Array.isArray(htmlReporter)
&& htmlReporter.length > 1
&& 'subdir' in htmlReporter[1]
? htmlReporter[1].subdir
: undefined

if (!subdir || typeof subdir !== 'string') {
return [root, `/${basename(root)}/`]
}

return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
}
10 changes: 10 additions & 0 deletions packages/ui/node/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,14 @@ export default class HTMLReporter implements Reporter {
)}${c.dim(' to see the test results.')}`,
)
}

async onFinishedReportCoverage(): Promise<void> {
if (this.ctx.config.coverage.enabled && this.ctx.config.coverage.htmlDir) {
const coverageHtmlDir = this.ctx.config.coverage.htmlDir
const destCoverageDir = resolve(this.reporterDir, 'coverage')
await fs.rm(destCoverageDir, { recursive: true, force: true })
await fs.mkdir(destCoverageDir, { recursive: true })
await fs.cp(coverageHtmlDir, destCoverageDir, { recursive: true })
}
}
}
25 changes: 25 additions & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,31 @@ export function resolveConfig(
`You cannot set "coverage.reportsDirectory" as ${reportsDirectory}. Vitest needs to be able to remove this directory before test run`,
)
}

if (resolved.coverage.htmlDir) {
resolved.coverage.htmlDir = resolve(
resolved.root,
resolved.coverage.htmlDir,
)
}

// infer default htmlDir based on builtin reporter's html output location
if (!resolved.coverage.htmlDir) {
const htmlReporter = resolved.coverage.reporter.find(([name]) => name === 'html' || name === 'html-spa')
if (htmlReporter) {
const [, options] = htmlReporter
const subdir = options && typeof options === 'object' && 'subdir' in options && typeof options.subdir === 'string'
? options.subdir
: undefined
resolved.coverage.htmlDir = resolve(reportsDirectory, subdir || '.')
}
else {
const lcovReporter = resolved.coverage.reporter.find(([name]) => name === 'lcov')
if (lcovReporter) {
resolved.coverage.htmlDir = resolve(reportsDirectory, 'lcov-report')
}
}
}
}

if (resolved.coverage.enabled && resolved.coverage.provider === 'custom' && resolved.coverage.customProviderModule) {
Expand Down
9 changes: 1 addition & 8 deletions packages/vitest/src/node/config/serializeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,14 @@ export function serializeConfig(project: TestProject): SerializedConfig {
snapshotEnvironment: config.snapshotEnvironment,
passWithNoTests: config.passWithNoTests,
coverage: ((coverage) => {
const htmlReporter = coverage.reporter.find(([reporterName]) => reporterName === 'html') as [
'html',
{ subdir?: string },
] | undefined
const subdir = htmlReporter && htmlReporter[1]?.subdir
return {
reportsDirectory: coverage.reportsDirectory,
provider: coverage.provider,
enabled: coverage.enabled,
htmlReporter: htmlReporter
? { subdir }
: undefined,
customProviderModule: 'customProviderModule' in coverage
? coverage.customProviderModule
: undefined,
htmlDir: coverage.htmlDir,
}
})(config.coverage),
fakeTimers: config.fakeTimers,
Expand Down
10 changes: 6 additions & 4 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpe
import { join, normalize, relative } from 'pathe'
import { isRunnableDevEnvironment } from 'vite'
import { version } from '../../package.json' with { type: 'json' }
import { WebSocketReporter } from '../api/setup'
import { distDir } from '../paths'
import { wildcardPatternToRegExp } from '../utils/base'
import { NativeModuleRunner } from '../utils/nativeModuleRunner'
Expand Down Expand Up @@ -1379,10 +1378,13 @@ export class Vitest {

if (this.coverageProvider) {
await this.coverageProvider.reportCoverage(coverage, { allTestsRun })
// notify coverage iframe reload
// notify builtin ui and html reporter after coverage html is generated
for (const reporter of this.reporters) {
if (reporter instanceof WebSocketReporter) {
reporter.onFinishedReportCoverage()
if (
'onFinishedReportCoverage' in reporter
&& typeof reporter.onFinishedReportCoverage === 'function'
) {
await reporter.onFinishedReportCoverage()
Comment on lines +1381 to +1387
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reportCoverage happens after onTestRunEnd, so now HTML reporter also needs this to be able to collect coverage html.

Copy link
Member

@sheremet-va sheremet-va Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AriPerkkio that's why I wanted onCoverage as a separate hook, by the way. We have it, but it's coupled to onTestRunEnd

}
}
}
Expand Down
Loading
Loading