Skip to content

Commit 86fad4b

Browse files
hi-ogawaclaude
andauthored
fix: fix ui mode / html reporter and coverage integration (#9626)
Co-authored-by: Claude Sonnet 4.5 <[email protected]>
1 parent 9ee999d commit 86fad4b

File tree

17 files changed

+145
-170
lines changed

17 files changed

+145
-170
lines changed

docs/config/coverage.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,6 @@ Vitest will delete this directory before running tests if `coverage.clean` is en
8989

9090
Directory to write coverage report to.
9191

92-
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`).
93-
9492
## coverage.reporter
9593

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

397395
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.
396+
397+
## coverage.htmlDir
398+
399+
- **Type:** `string`
400+
- **Default:** Automatically inferred from `html`, `html-spa`, or `lcov` coverage reporters
401+
- **CLI:** `--coverage.htmlDir=<path>`
402+
403+
Directory of HTML coverage output to be served in [Vitest UI](/guide/ui) and [HTML reporter](/guide/reporters.html#html-reporter).
404+
405+
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.
406+
407+
Note that setting this option does not change where coverage HTML report is generated. Configure the `coverage.reporter` option to change the directory instead.

docs/guide/coverage.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,11 +499,9 @@ If code coverage generation is slow on your project, see [Profiling Test Perform
499499

500500
## Vitest UI
501501

502-
You can check your coverage report in [Vitest UI](/guide/ui).
502+
You can check your coverage report in [Vitest UI](/guide/ui) and [HTML reporter](/guide/reporters.html#html-reporter).
503503

504-
Vitest UI will enable coverage report when it is enabled explicitly and the html coverage reporter is present, otherwise it will not be available:
505-
- enable `coverage.enabled=true` in your configuration file or run Vitest with `--coverage.enabled=true` flag
506-
- add `html` to the `coverage.reporter` list: you can also enable `subdir` option to put coverage report in a subdirectory
504+
This is integrated with builtin coverage reporters with HTML output (`html`, `html-spa`, and `lcov` reporters). `html` reporter is enabled by default and this works out of the box. To integrate with custom reporters, you can configure [`coverage.htmlDir`](/config/coverage#coverage-htmldir).
507505

508506
<img alt="html coverage activation in Vitest UI" img-light src="/vitest-ui-show-coverage-light.png">
509507
<img alt="html coverage activation in Vitest UI" img-dark src="/vitest-ui-show-coverage-dark.png">

packages/browser/src/node/plugin.ts

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import type { HtmlTagDescriptor } from 'vite'
22
import type { Plugin } from 'vitest/config'
3-
import type { Vitest } from 'vitest/node'
43
import type { ParentBrowserProject } from './projectParent'
54
import { createReadStream, readFileSync } from 'node:fs'
65
import { createRequire } from 'node:module'
76
import { dynamicImportPlugin } from '@vitest/mocker/node'
87
import { toArray } from '@vitest/utils/helpers'
98
import MagicString from 'magic-string'
10-
import { basename, dirname, join, resolve } from 'pathe'
9+
import { dirname, join, resolve } from 'pathe'
1110
import sirv from 'sirv'
12-
import { coverageConfigDefaults } from 'vitest/config'
1311
import {
1412
isFileServingAllowed,
1513
isValidApiRequest,
@@ -63,18 +61,12 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
6361
},
6462
)
6563

66-
const coverageFolder = resolveCoverageFolder(parentServer.vitest)
67-
const coveragePath = coverageFolder ? coverageFolder[1] : undefined
68-
if (coveragePath && base === coveragePath) {
69-
throw new Error(
70-
`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`,
71-
)
72-
}
73-
74-
if (coverageFolder) {
64+
// Serve coverage HTML at ./coverage if configured
65+
const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir
66+
if (coverageHtmlDir) {
7567
server.middlewares.use(
76-
coveragePath!,
77-
sirv(coverageFolder[0], {
68+
'/__vitest_test__/coverage',
69+
sirv(coverageHtmlDir, {
7870
single: true,
7971
dev: true,
8072
setHeaders: (res) => {
@@ -604,43 +596,6 @@ function getRequire() {
604596
return _require
605597
}
606598

607-
function resolveCoverageFolder(vitest: Vitest) {
608-
const options = vitest.config
609-
const coverageOptions = vitest._coverageOptions
610-
const htmlReporter = coverageOptions?.enabled
611-
? toArray(options.coverage.reporter).find((reporter) => {
612-
if (typeof reporter === 'string') {
613-
return reporter === 'html'
614-
}
615-
616-
return reporter[0] === 'html'
617-
})
618-
: undefined
619-
620-
if (!htmlReporter) {
621-
return undefined
622-
}
623-
624-
// reportsDirectory not resolved yet
625-
const root = resolve(
626-
options.root || process.cwd(),
627-
coverageOptions.reportsDirectory || coverageConfigDefaults.reportsDirectory,
628-
)
629-
630-
const subdir
631-
= Array.isArray(htmlReporter)
632-
&& htmlReporter.length > 1
633-
&& 'subdir' in htmlReporter[1]
634-
? htmlReporter[1].subdir
635-
: undefined
636-
637-
if (!subdir || typeof subdir !== 'string') {
638-
return [root, `/${basename(root)}/`]
639-
}
640-
641-
return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
642-
}
643-
644599
const postfixRE = /[?#].*$/
645600
function cleanUrl(url: string): string {
646601
return url.replace(postfixRE, '')

packages/ui/client/components/Coverage.vue

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
<script setup lang="ts">
22
import DetailsHeaderButtons from '~/components/DetailsHeaderButtons.vue'
33
import { browserState } from '~/composables/client'
4-
5-
defineProps<{
6-
src: string
7-
}>()
84
</script>
95

106
<template>
@@ -15,7 +11,7 @@ defineProps<{
1511
<DetailsHeaderButtons v-if="browserState" />
1612
</div>
1713
<div flex-auto py-1 bg-white>
18-
<iframe id="vitest-ui-coverage" :src="src" />
14+
<iframe id="vitest-ui-coverage" src="./coverage/index.html" />
1915
</div>
2016
</div>
2117
</template>

packages/ui/client/composables/attachments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function getAttachmentUrl(attachment: TestAttachment): string {
88
if (attachment.path) {
99
if (isReport) {
1010
// html reporter copies attachments to /data/ folder
11-
return `/data/${basename(attachment.path)}`
11+
return `./data/${basename(attachment.path)}`
1212
}
1313
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
1414
}

packages/ui/client/composables/navigation.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const coverageConfigured = computed(() => coverage.value?.enabled)
1717
export const coverageEnabled = computed(() => {
1818
return (
1919
coverageConfigured.value
20-
&& !!coverage.value?.htmlReporter
20+
&& !!coverage.value?.htmlDir
2121
)
2222
})
2323
export const mainSizes = useLocalStorage<[left: number, right: number]>(
@@ -71,23 +71,6 @@ export const panels = reactive({
7171
},
7272
})
7373

74-
// TODO
75-
// For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
76-
// Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.
77-
export const coverageUrl = computed(() => {
78-
if (coverageEnabled.value) {
79-
const idx = coverage.value!.reportsDirectory.lastIndexOf('/')
80-
const htmlReporterSubdir = coverage.value!.htmlReporter?.subdir
81-
return htmlReporterSubdir
82-
? `/${coverage.value!.reportsDirectory.slice(idx + 1)}/${
83-
htmlReporterSubdir
84-
}/index.html`
85-
: `/${coverage.value!.reportsDirectory.slice(idx + 1)}/index.html`
86-
}
87-
88-
return undefined
89-
})
90-
9174
watch(
9275
testRunState,
9376
(state) => {

packages/ui/client/pages/index.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import Navigation from '~/components/Navigation.vue'
1212
import ProgressBar from '~/components/ProgressBar.vue'
1313
import { browserState } from '~/composables/client'
1414
import {
15-
coverageUrl,
1615
coverageVisible,
1716
detailSizes,
1817
detailsPanelVisible,
@@ -97,7 +96,6 @@ function allowBrowserEvents() {
9796
<Coverage
9897
v-else-if="coverageVisible"
9998
key="coverage"
100-
:src="coverageUrl!"
10199
/>
102100
<FileDetails v-else key="details" />
103101
</transition>
@@ -127,7 +125,6 @@ function allowBrowserEvents() {
127125
<Coverage
128126
v-else-if="coverageVisible"
129127
key="coverage"
130-
:src="coverageUrl!"
131128
/>
132129
<FileDetails v-else key="details" />
133130
</div>

packages/ui/node/index.ts

Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import type { Plugin } from 'vite'
22
import type { Vitest } from 'vitest/node'
33
import fs from 'node:fs'
44
import { fileURLToPath } from 'node:url'
5-
import { toArray } from '@vitest/utils/helpers'
6-
import { basename, resolve } from 'pathe'
5+
import { join, resolve } from 'pathe'
76
import sirv from 'sirv'
87
import c from 'tinyrainbow'
9-
import { coverageConfigDefaults } from 'vitest/config'
108
import { isFileServingAllowed, isValidApiRequest } from 'vitest/node'
119
import { version } from '../package.json'
1210

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

40-
if (coverageFolder) {
31+
// Serve coverage HTML at ./coverage if configured
32+
const coverageHtmlDir = ctx.config.coverage?.htmlDir
33+
if (coverageHtmlDir) {
4134
server.middlewares.use(
42-
coveragePath!,
43-
sirv(coverageFolder[0], {
35+
join(base, 'coverage'),
36+
sirv(coverageHtmlDir, {
4437
single: true,
4538
dev: true,
4639
setHeaders: (res) => {
@@ -126,40 +119,3 @@ export default (ctx: Vitest): Plugin => {
126119
},
127120
}
128121
}
129-
130-
function resolveCoverageFolder(ctx: Vitest) {
131-
const options = ctx.config
132-
const htmlReporter
133-
= options.api?.port && options.coverage?.enabled
134-
? toArray(options.coverage.reporter).find((reporter) => {
135-
if (typeof reporter === 'string') {
136-
return reporter === 'html'
137-
}
138-
139-
return reporter[0] === 'html'
140-
})
141-
: undefined
142-
143-
if (!htmlReporter) {
144-
return undefined
145-
}
146-
147-
// reportsDirectory not resolved yet
148-
const root = resolve(
149-
ctx.config?.root || options.root || process.cwd(),
150-
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
151-
)
152-
153-
const subdir
154-
= Array.isArray(htmlReporter)
155-
&& htmlReporter.length > 1
156-
&& 'subdir' in htmlReporter[1]
157-
? htmlReporter[1].subdir
158-
: undefined
159-
160-
if (!subdir || typeof subdir !== 'string') {
161-
return [root, `/${basename(root)}/`]
162-
}
163-
164-
return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
165-
}

packages/ui/node/reporter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,14 @@ export default class HTMLReporter implements Reporter {
154154
)}${c.dim(' to see the test results.')}`,
155155
)
156156
}
157+
158+
async onFinishedReportCoverage(): Promise<void> {
159+
if (this.ctx.config.coverage.enabled && this.ctx.config.coverage.htmlDir) {
160+
const coverageHtmlDir = this.ctx.config.coverage.htmlDir
161+
const destCoverageDir = resolve(this.reporterDir, 'coverage')
162+
await fs.rm(destCoverageDir, { recursive: true, force: true })
163+
await fs.mkdir(destCoverageDir, { recursive: true })
164+
await fs.cp(coverageHtmlDir, destCoverageDir, { recursive: true })
165+
}
166+
}
157167
}

packages/vitest/src/node/config/resolveConfig.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,31 @@ export function resolveConfig(
434434
`You cannot set "coverage.reportsDirectory" as ${reportsDirectory}. Vitest needs to be able to remove this directory before test run`,
435435
)
436436
}
437+
438+
if (resolved.coverage.htmlDir) {
439+
resolved.coverage.htmlDir = resolve(
440+
resolved.root,
441+
resolved.coverage.htmlDir,
442+
)
443+
}
444+
445+
// infer default htmlDir based on builtin reporter's html output location
446+
if (!resolved.coverage.htmlDir) {
447+
const htmlReporter = resolved.coverage.reporter.find(([name]) => name === 'html' || name === 'html-spa')
448+
if (htmlReporter) {
449+
const [, options] = htmlReporter
450+
const subdir = options && typeof options === 'object' && 'subdir' in options && typeof options.subdir === 'string'
451+
? options.subdir
452+
: undefined
453+
resolved.coverage.htmlDir = resolve(reportsDirectory, subdir || '.')
454+
}
455+
else {
456+
const lcovReporter = resolved.coverage.reporter.find(([name]) => name === 'lcov')
457+
if (lcovReporter) {
458+
resolved.coverage.htmlDir = resolve(reportsDirectory, 'lcov-report')
459+
}
460+
}
461+
}
437462
}
438463

439464
if (resolved.coverage.enabled && resolved.coverage.provider === 'custom' && resolved.coverage.customProviderModule) {

0 commit comments

Comments
 (0)