Skip to content

Commit e24cd9b

Browse files
feat(ui): add html coverage (#3071)
Co-authored-by: Vladimir Sheremet <sleuths.slews0s@icloud.com>
1 parent 78bad4a commit e24cd9b

23 files changed

+216
-20
lines changed

.eslintrc

+12
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@
2323
}
2424
]
2525
}
26+
},
27+
{
28+
// these files define vitest as peer dependency
29+
"files": "packages/{coverage-*,ui,browser}/**/*.*",
30+
"rules": {
31+
"no-restricted-imports": [
32+
"error",
33+
{
34+
"paths": ["path"]
35+
}
36+
]
37+
}
2638
}
2739
]
2840
}

docs/config/index.md

+2
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,8 @@ The reporter has three different types:
776776
}
777777
```
778778

779+
Since Vitest 0.31.0, you can check your coverage report in Vitest UI: check [Vitest UI Coverage](/guide/coverage#vitest-ui) for more details.
780+
779781
#### coverage.skipFull
780782

781783
- **Type:** `boolean`

docs/guide/coverage.md

+12
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,15 @@ if (condition) {
164164
## Other Options
165165
166166
To see all configurable options for coverage, see the [coverage Config Reference](https://vitest.dev/config/#coverage).
167+
168+
## Vitest UI
169+
170+
Since Vitest 0.31.0, you can check your coverage report in [Vitest UI](./ui).
171+
172+
If you have configured coverage reporters, don't forget to add `html` reporter to the list, Vitest UI will only enable html coverage report if it is present.
173+
174+
<img alt="html coverage activation in Vitest UI" img-light src="/vitest-ui-show-coverage-light.png">
175+
<img alt="html coverage activation in Vitest UI" img-dark src="/vitest-ui-show-coverage-dark.png">
176+
177+
<img alt="html coverage in Vitest UI" img-light src="/vitest-ui-coverage-light.png">
178+
<img alt="html coverage in Vitest UI" img-dark src="/vitest-ui-coverage-dark.png">

docs/guide/ui.md

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export default {
3434
}
3535
```
3636

37+
Since Vitest 0.31.0, you can check your coverage report in Vitest UI: check [Vitest UI Coverage](/guide/coverage#vitest-ui) for more details.
38+
3739
::: warning
3840
If you still want to see how your tests are running in real time in the terminal, don't forget to add `default` reporter to `reporters` option: `['default', 'html']`.
3941
:::
76.1 KB
Loading
77.2 KB
Loading
3.49 KB
Loading
3.48 KB
Loading

packages/browser/src/client/main.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createClient } from '@vitest/ws-client'
2-
// eslint-disable-next-line no-restricted-imports
32
import type { ResolvedConfig } from 'vitest'
43
import type { CancelReason, VitestRunner } from '@vitest/runner'
54
import { createBrowserRunner } from './runner'

packages/coverage-c8/src/provider.ts

-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { provider } from 'std-env'
99
import type { EncodedSourceMap } from 'vite-node'
1010
import { coverageConfigDefaults } from 'vitest/config'
1111
import { BaseCoverageProvider } from 'vitest/coverage'
12-
// eslint-disable-next-line no-restricted-imports
1312
import type { AfterSuiteRunMeta, CoverageC8Options, CoverageProvider, ReportContext, ResolvedCoverageOptions } from 'vitest'
1413
import type { Vitest } from 'vitest/node'
1514
import type { Report } from 'c8'

packages/coverage-istanbul/src/provider.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable no-restricted-imports */
21
import { existsSync, promises as fs } from 'node:fs'
32
import { relative, resolve } from 'pathe'
43
import type { TransformPluginContext } from 'rollup'

packages/ui/client/components.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ declare module '@vue/runtime-core' {
1111
export interface GlobalComponents {
1212
CodeMirror: typeof import('./components/CodeMirror.vue')['default']
1313
ConnectionOverlay: typeof import('./components/ConnectionOverlay.vue')['default']
14+
Coverage: typeof import('./components/Coverage.vue')['default']
1415
Dashboard: typeof import('./components/Dashboard.vue')['default']
1516
DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default']
1617
DetailsPanel: typeof import('./components/DetailsPanel.vue')['default']
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
src: string
4+
}>()
5+
</script>
6+
7+
<template>
8+
<div h="full" flex="~ col">
9+
<div
10+
p="3"
11+
h-10
12+
flex="~ gap-2"
13+
items-center
14+
bg-header
15+
border="b base"
16+
>
17+
<div class="i-carbon:folder-details-reference" />
18+
<span
19+
pl-1
20+
font-bold
21+
text-sm
22+
flex-auto
23+
ws-nowrap
24+
overflow-hidden
25+
truncate
26+
>Coverage</span>
27+
</div>
28+
<div flex-auto py-1 bg-white>
29+
<iframe id="vitest-ui-coverage" :src="src" />
30+
</div>
31+
</div>
32+
</template>
33+
34+
<style>
35+
#vitest-ui-coverage {
36+
width: 100%;
37+
height: calc(100vh - 42px);
38+
border: none;
39+
}
40+
</style>

packages/ui/client/components/Navigation.vue

+56-8
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
<script setup lang="ts">
22
import { hasFailedSnapshot } from '@vitest/ws-client'
3-
import { currentModule, dashboardVisible, showDashboard } from '../composables/navigation'
3+
import { Tooltip as VueTooltip } from 'floating-vue'
4+
import {
5+
coverageConfigured,
6+
coverageEnabled,
7+
coverageVisible,
8+
currentModule,
9+
dashboardVisible,
10+
disableCoverage,
11+
showCoverage,
12+
showDashboard,
13+
} from '../composables/navigation'
414
import { client, findById } from '../composables/client'
5-
import type { Task } from '#types'
15+
import type { File, Task } from '#types'
616
import { isDark, toggleDark } from '~/composables'
717
import { files, isReport, runAll } from '~/composables/client'
818
import { activeFileId } from '~/composables/params'
@@ -18,39 +28,77 @@ function onItemClick(task: Task) {
1828
showDashboard(false)
1929
}
2030
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
31+
async function onRunAll(files?: File[]) {
32+
if (coverageEnabled.value) {
33+
disableCoverage.value = true
34+
await nextTick()
35+
if (coverageEnabled.value) {
36+
showDashboard(true)
37+
await nextTick()
38+
}
39+
}
40+
await runAll(files)
41+
}
2142
</script>
2243

2344
<template>
24-
<TasksList border="r base" :tasks="files" :on-item-click="onItemClick" :group-by-type="true" @run="runAll">
45+
<TasksList border="r base" :tasks="files" :on-item-click="onItemClick" :group-by-type="true" @run="onRunAll">
2546
<template #header="{ filteredTests }">
2647
<img w-6 h-6 src="/favicon.svg" alt="Vitest logo">
2748
<span font-light text-sm flex-1>Vitest</span>
2849
<div class="flex text-lg">
2950
<IconButton
30-
v-show="!dashboardVisible"
51+
v-show="(coverageConfigured && !coverageEnabled) || !dashboardVisible"
3152
v-tooltip.bottom="'Dashboard'"
3253
title="Show dashboard"
3354
class="!animate-100ms"
3455
animate-count-1
35-
icon="i-carbon-dashboard"
56+
icon="i-carbon:dashboard"
3657
@click="showDashboard(true)"
3758
/>
59+
<VueTooltip
60+
v-if="coverageConfigured && !coverageEnabled"
61+
title="Coverage enabled but missing html reporter"
62+
class="w-1.4em h-1.4em op100 rounded flex color-red5 dark:color-#f43f5e cursor-help"
63+
>
64+
<div class="i-carbon:folder-off ma" />
65+
<template #popper>
66+
<div class="op100 gap-1 p-y-1" grid="~ items-center cols-[1.5em_1fr]">
67+
<div class="i-carbon:information-square w-1.5em h-1.5em" />
68+
<div>Coverage enabled but missing html reporter.</div>
69+
<div style="grid-column: 2">
70+
Add html reporter to your configuration to see coverage here.
71+
</div>
72+
</div>
73+
</template>
74+
</VueTooltip>
75+
<IconButton
76+
v-if="coverageEnabled"
77+
v-show="!coverageVisible"
78+
v-tooltip.bottom="'Coverage'"
79+
:disabled="disableCoverage"
80+
title="Show coverage"
81+
class="!animate-100ms"
82+
animate-count-1
83+
icon="i-carbon:folder-details-reference"
84+
@click="showCoverage()"
85+
/>
3886
<IconButton
3987
v-if="(failedSnapshot && !isReport)"
4088
v-tooltip.bottom="'Update all failed snapshot(s)'"
41-
icon="i-carbon-result-old"
89+
icon="i-carbon:result-old"
4290
@click="updateSnapshot()"
4391
/>
4492
<IconButton
4593
v-if="!isReport"
4694
v-tooltip.bottom="filteredTests ? (filteredTests.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
4795
:disabled="filteredTests?.length === 0"
48-
icon="i-carbon-play"
96+
icon="i-carbon:play"
4997
@click="runAll(filteredTests)"
5098
/>
5199
<IconButton
52100
v-tooltip.bottom="`Toggle to ${toggleMode} mode`"
53-
icon="dark:i-carbon-moon i-carbon-sun"
101+
icon="dark:i-carbon-moon i-carbon:sun"
54102
@click="toggleDark()"
55103
/>
56104
</div>

packages/ui/client/components/Suites.vue

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { hasFailedSnapshot } from '@vitest/ws-client'
3+
import { coverageEnabled, disableCoverage } from '../composables/navigation'
34
import { client, current, isReport, runCurrent } from '~/composables/client'
45
56
const name = computed(() => current.value?.name.split(/\//g).pop())
@@ -8,6 +9,13 @@ const failedSnapshot = computed(() => current.value?.tasks && hasFailedSnapshot(
89
function updateSnapshot() {
910
return current.value && client.rpc.updateSnapshot(current.value)
1011
}
12+
async function onRunCurrent() {
13+
if (coverageEnabled.value) {
14+
disableCoverage.value = true
15+
await nextTick()
16+
}
17+
await runCurrent()
18+
}
1119
</script>
1220

1321
<template>
@@ -27,7 +35,7 @@ function updateSnapshot() {
2735
v-if="!isReport"
2836
v-tooltip.bottom="'Rerun file'"
2937
icon="i-carbon-play"
30-
@click="runCurrent()"
38+
@click="onRunCurrent()"
3139
/>
3240
</div>
3341
</template>

packages/ui/client/composables/navigation.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,50 @@
1-
import { client, findById } from './client'
1+
import { client, config, findById, testRunState } from './client'
22
import { activeFileId } from './params'
33
import type { File } from '#types'
44

55
export const currentModule = ref<File>()
66
export const dashboardVisible = ref(true)
7+
export const coverageVisible = ref(false)
8+
export const disableCoverage = ref(true)
9+
export const coverage = computed(() => config.value?.coverage)
10+
export const coverageConfigured = computed(() => {
11+
if (!config.value?.api?.port)
12+
return false
713

14+
return coverage.value?.enabled
15+
})
16+
export const coverageEnabled = computed(() => {
17+
return coverageConfigured.value
18+
&& coverage.value.reporter.map(([reporterName]) => reporterName).includes('html')
19+
})
20+
export const coverageUrl = computed(() => {
21+
if (coverageEnabled.value) {
22+
const url = `${window.location.protocol}//${window.location.hostname}:${config.value!.api!.port!}`
23+
const idx = coverage.value!.reportsDirectory.lastIndexOf('/')
24+
return `${url}/${coverage.value!.reportsDirectory.slice(idx + 1)}/index.html`
25+
}
26+
27+
return undefined
28+
})
29+
watch(testRunState, (state) => {
30+
disableCoverage.value = state === 'running'
31+
}, { immediate: true })
832
export function initializeNavigation() {
933
const file = activeFileId.value
1034
if (file && file.length > 0) {
1135
const current = findById(file)
1236
if (current) {
1337
currentModule.value = current
1438
dashboardVisible.value = false
39+
coverageVisible.value = false
1540
}
1641
else {
1742
watchOnce(
1843
() => client.state.getFiles(),
1944
() => {
2045
currentModule.value = findById(file)
2146
dashboardVisible.value = false
47+
coverageVisible.value = false
2248
},
2349
)
2450
}
@@ -29,8 +55,16 @@ export function initializeNavigation() {
2955

3056
export function showDashboard(show: boolean) {
3157
dashboardVisible.value = show
58+
coverageVisible.value = false
3259
if (show) {
3360
currentModule.value = undefined
3461
activeFileId.value = ''
3562
}
3663
}
64+
65+
export function showCoverage() {
66+
coverageVisible.value = true
67+
dashboardVisible.value = false
68+
currentModule.value = undefined
69+
activeFileId.value = ''
70+
}

packages/ui/client/pages/index.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
// @ts-expect-error missing types
33
import { Pane, Splitpanes } from 'splitpanes'
4-
import { initializeNavigation } from '../composables/navigation'
4+
import { coverageUrl, coverageVisible, initializeNavigation } from '../composables/navigation'
55
66
const dashboardVisible = initializeNavigation()
77
const mainSizes = reactive([33, 67])
@@ -39,6 +39,7 @@ function resizeMain() {
3939
<Pane :size="mainSizes[1]">
4040
<transition>
4141
<Dashboard v-if="dashboardVisible" key="summary" />
42+
<Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" />
4243
<Splitpanes v-else key="detail" @resized="onModuleResized">
4344
<Pane :size="detailSizes[0]">
4445
<Suites />

packages/ui/node/index.ts

+36-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import { fileURLToPath } from 'node:url'
2-
import { resolve } from 'pathe'
2+
import { basename, resolve } from 'pathe'
33
import sirv from 'sirv'
44
import type { Plugin } from 'vite'
5+
import { coverageConfigDefaults } from 'vitest/config'
6+
import type { ResolvedConfig, Vitest } from 'vitest'
57

6-
export default (base = '/__vitest__/') => {
8+
export default (ctx: Vitest) => {
79
return <Plugin>{
810
name: 'vitest:ui',
911
apply: 'serve',
10-
async configureServer(server) {
12+
configureServer(server) {
13+
const uiOptions: ResolvedConfig = ctx.config
14+
const base = uiOptions.uiBase
15+
const coverageFolder = resolveCoverageFolder(ctx)
16+
const coveragePath = coverageFolder ? `/${basename(coverageFolder)}/` : undefined
17+
if (coveragePath && base === coveragePath)
18+
throw new Error(`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`)
19+
20+
coverageFolder && server.middlewares.use(coveragePath!, sirv(coverageFolder, {
21+
single: true,
22+
dev: true,
23+
}))
1124
const clientDist = resolve(fileURLToPath(import.meta.url), '../client')
1225
server.middlewares.use(base, sirv(clientDist, {
1326
single: true,
@@ -16,3 +29,23 @@ export default (base = '/__vitest__/') => {
1629
},
1730
}
1831
}
32+
33+
function resolveCoverageFolder(ctx: Vitest) {
34+
const options: ResolvedConfig = ctx.config
35+
const enabled = options.api?.port
36+
&& options.coverage?.enabled
37+
&& options.coverage.reporter.some((reporter) => {
38+
if (typeof reporter === 'string')
39+
return reporter === 'html'
40+
41+
return reporter.length && reporter.includes('html')
42+
})
43+
44+
// reportsDirectory not resolved yet
45+
return enabled
46+
? resolve(
47+
ctx.config?.root || options.root || process.cwd(),
48+
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
49+
)
50+
: undefined
51+
}

0 commit comments

Comments
 (0)