Skip to content

Commit

Permalink
feat: add html reporter based on Vitest UI (#2444)
Browse files Browse the repository at this point in the history
* feat: vitest html report

* chore: copy ui to vitest dist

* feat: report copy ui dist

* feat: ui report version builder

* fix: copy file

* chore: remove

* feat: html metadata path

* feat: add declare

* feat: static file client

* feat: mock rpc

* fix: mock error

* chore: update meta

* chore: compress json

* chore: comment

* chore: update merge config

* chore: lock

* feat: remove all control command from ui

* chore: remove

* feat: report command

* feat: version tag

* chore: reset lock file

* feat: remove hooks

* chore: update

* fix: runningPromise

* chore: update debug mode and disable click on transform

* docs: report

* chore: remove the version mark

* feat: report

* fix: lint

* chore: copy ui from @vitest/ui

* chore: export report from ui

* chore: update

* fix: lint

* docs: ui html report

* feat: ensurePackageInstalled

* update

* feat: more info

* chore: improve documentation and tests

* chore: fix UI bundle size

* chore: ui tests

* perf: remove output report using the global variable to judge report mode

* chore: update

* fix: build

* fix: report

* fix: parse

* chore: fix html reporters test

* chore: don't store config in html reporter test

* chore: update ui docs

* feat: log

* chore: fix tests

* test: fix html reporter tests

* docs add vitest fo UI reporter

Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
  • Loading branch information
poyoho and sheremet-va committed Dec 19, 2022
1 parent 881bd7a commit b8f34eb
Show file tree
Hide file tree
Showing 31 changed files with 641 additions and 151 deletions.
1 change: 1 addition & 0 deletions docs/config/index.md
Expand Up @@ -341,6 +341,7 @@ Custom reporters for output. Reporters can be [a Reporter instance](https://gith
- `'dot'` - show each task as a single dot
- `'junit'` - JUnit XML reporter (you can configure `testsuites` tag name with `VITEST_JUNIT_SUITE_NAME` environmental variable)
- `'json'` - give a simple JSON summary
- `'html'` - outputs HTML report based on [`@vitest/ui`](/guide/ui)
- path of a custom reporter (e.g. `'./path/to/reporter.ts'`, `'@scope/reporter'`)

### outputTruncateLength
Expand Down
26 changes: 26 additions & 0 deletions docs/guide/ui.md
Expand Up @@ -21,3 +21,29 @@ Then you can visit the Vitest UI at <a href="http://localhost:51204/__vitest__/"

<img alt="Vitest UI" img-light src="https://user-images.githubusercontent.com/11247099/171992267-5cae2fa0-b927-400a-8eb1-da776974cb61.png">
<img alt="Vitest UI" img-dark src="https://user-images.githubusercontent.com/11247099/171992272-7c6057e2-80c3-4b17-a7b6-0ac28e5a5e0b.png">

Since Vitest 0.26.0, UI can also be used as a reporter. Use `'html'` reporter in your Vitest configuration to generate HTML output and preview results of your tests:

```ts
// vitest.config.ts

export default {
test: {
reporters: ['html']
}
}
```

::: warning
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']`.
:::

::: tip
To preview your HTML report, you can use [vite preview](https://vitejs.dev/guide/cli.html#vite-preview) command:

```sh
npx vite preview --base __vitest__ --outDir ./html
```

You can configure output with [`outputFile`](/config/#outputfile) config option. You need to specify `.html` path there. For example, `./html/index.html` is the default value.
:::
4 changes: 3 additions & 1 deletion packages/ui/client/components/FileDetails.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client, current, currentLogs } from '~/composables/client'
import { client, current, currentLogs, isReport } from '~/composables/client'
import type { Params } from '~/composables/params'
import { viewMode } from '~/composables/params'
import type { ModuleGraph } from '~/composables/module-graph'
Expand Down Expand Up @@ -52,6 +52,7 @@ function onDraft(value: boolean) {
</div>
<div class="flex text-lg">
<IconButton
v-if="!isReport"
v-tooltip.bottom="'Open in editor'"
title="Open in editor"
icon="i-carbon-launch"
Expand All @@ -76,6 +77,7 @@ function onDraft(value: boolean) {
Module Graph
</button>
<button
v-if="!isReport"
tab-button
:class="{ 'tab-button-active': viewMode === 'editor' }"
@click="changeViewMode('editor')"
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/client/components/Navigation.vue
Expand Up @@ -4,7 +4,7 @@ import { currentModule, dashboardVisible, showDashboard } from '../composables/n
import { client, findById } from '../composables/client'
import type { Task } from '#types'
import { isDark, toggleDark } from '~/composables'
import { files, runAll } from '~/composables/client'
import { files, isReport, runAll } from '~/composables/client'
import { activeFileId } from '~/composables/params'
const failedSnapshot = computed(() => files.value && hasFailedSnapshot(files.value))
Expand Down Expand Up @@ -34,12 +34,13 @@ const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
@click="showDashboard(true)"
/>
<IconButton
v-if="failedSnapshot"
v-if="(failedSnapshot && !isReport)"
v-tooltip.bottom="'Update all failed snapshot(s)'"
icon="i-carbon-result-old"
@click="updateSnapshot()"
/>
<IconButton
v-if="!isReport"
v-tooltip.bottom="filteredTests ? (filteredTests.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
:disabled="filteredTests?.length === 0"
icon="i-carbon-play"
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/client/components/Suites.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { hasFailedSnapshot } from '@vitest/ws-client'
import { client, current, runCurrent } from '~/composables/client'
import { client, current, isReport, runCurrent } from '~/composables/client'
const name = computed(() => current.value?.name.split(/\//g).pop())
Expand All @@ -16,12 +16,13 @@ const updateSnapshot = () => current.value && client.rpc.updateSnapshot(current.
<span font-bold text-sm flex-auto ws-nowrap overflow-hidden truncate>{{ name }}</span>
<div class="flex text-lg">
<IconButton
v-if="failedSnapshot"
v-if="(failedSnapshot && !isReport)"
v-tooltip.bottom="`Update failed snapshot(s) of ${current.name}`"
icon="i-carbon-result-old"
@click="updateSnapshot()"
/>
<IconButton
v-if="!isReport"
v-tooltip.bottom="'Rerun file'"
icon="i-carbon-play"
@click="runCurrent()"
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/client/components/views/ViewModuleGraph.vue
Expand Up @@ -2,6 +2,7 @@
import type { ResizeContext } from 'd3-graph-controller'
import { GraphController, Markers, PositionInitializers, defineGraphConfig } from 'd3-graph-controller'
import type { Selection } from 'd3-selection'
import { isReport } from '~/composables/client'
import type { ModuleGraph, ModuleGraphController, ModuleLink, ModuleNode, ModuleType } from '~/composables/module-graph'
const props = defineProps<{
Expand Down Expand Up @@ -87,6 +88,8 @@ function resetGraphController() {
}
function bindOnClick(selection: Selection<SVGCircleElement, ModuleNode, SVGGElement, undefined>) {
if (isReport)
return
// Only trigger on left-click and primary touch
const isValidClick = (event: PointerEvent) => event.button === 0
Expand Down
Expand Up @@ -2,27 +2,35 @@ import { createClient, getTasks } from '@vitest/ws-client'
import type { WebSocketStatus } from '@vueuse/core'
import type { Ref } from 'vue'
import { reactive } from 'vue'
import type { RunState } from '../../types'
import { activeFileId } from './params'
import type { RunState } from '../../../types'
import { activeFileId } from '../params'
import { createStaticClient } from './static'
import type { File, ResolvedConfig } from '#types'

export const PORT = import.meta.hot ? '51204' : location.port
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
export const ENTRY_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${HOST}/__vitest_api__`

export const isReport = !!window.METADATA_PATH
export const testRunState: Ref<RunState> = ref('idle')

export const client = createClient(ENTRY_URL, {
reactive: reactive as any,
handlers: {
onTaskUpdate() {
testRunState.value = 'running'
},
onFinished() {
testRunState.value = 'idle'
},
},
})
export const client = (function createVitestClient() {
if (isReport) {
return createStaticClient()
}
else {
return createClient(ENTRY_URL, {
reactive: reactive as any,
handlers: {
onTaskUpdate() {
testRunState.value = 'running'
},
onFinished() {
testRunState.value = 'idle'
},
},
})
}
})()

export const config = shallowRef<ResolvedConfig>({} as any)
export const status = ref<WebSocketStatus>('CONNECTING')
Expand Down Expand Up @@ -60,11 +68,15 @@ watch(
(ws) => {
status.value = 'CONNECTING'

ws.addEventListener('open', () => {
ws.addEventListener('open', async () => {
status.value = 'OPEN'
client.state.filesMap.clear()
client.rpc.getFiles().then(files => client.state.collectFiles(files))
client.rpc.getConfig().then(_config => config.value = _config)
const [files, _config] = await Promise.all([
client.rpc.getFiles(),
client.rpc.getConfig(),
])
client.state.collectFiles(files)
config.value = _config
})

ws.addEventListener('close', () => {
Expand Down
84 changes: 84 additions & 0 deletions packages/ui/client/composables/client/static.ts
@@ -0,0 +1,84 @@
import type { BirpcReturn } from 'birpc'
import type { VitestClient } from '@vitest/ws-client'
import type { WebSocketHandlers } from 'vitest/src/api/types'
import { parse } from 'flatted'
import type { File, ModuleGraphData, ResolvedConfig } from 'vitest/src/types'
import { StateManager } from '../../../../vitest/src/node/state'

interface HTMLReportMetadata {
paths: string[]
files: File[]
config: ResolvedConfig
moduleGraph: Record<string, ModuleGraphData>
}

const noop: any = () => {}
const asyncNoop: any = () => Promise.resolve()

export function createStaticClient(): VitestClient {
const ctx = reactive({
state: new StateManager(),
waitForConnection,
reconnect,
ws: new EventTarget(),
}) as VitestClient

ctx.state.filesMap = reactive(ctx.state.filesMap)
ctx.state.idMap = reactive(ctx.state.idMap)

let metadata!: HTMLReportMetadata

const rpc = {
getFiles: () => {
return metadata.files
},
getPaths: async () => {
return metadata.paths
},
getConfig: () => {
return metadata.config
},
getModuleGraph: async (id) => {
return metadata.moduleGraph[id]
},
getTransformResult: async (id) => {
return {
code: id,
source: '',
}
},
readFile: async (id) => {
return Promise.resolve(id)
},
onWatcherStart: asyncNoop,
onFinished: asyncNoop,
onCollected: asyncNoop,
onTaskUpdate: noop,
writeFile: asyncNoop,
rerun: asyncNoop,
updateSnapshot: asyncNoop,
} as WebSocketHandlers

ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers>

let openPromise: Promise<void>

function reconnect() {
registerMetadata()
}

async function registerMetadata() {
const res = await fetch(window.METADATA_PATH!)
metadata = parse(await res.text()) as HTMLReportMetadata
const event = new Event('open')
ctx.ws.dispatchEvent(event)
}

registerMetadata()

function waitForConnection() {
return openPromise
}

return ctx
}
6 changes: 6 additions & 0 deletions packages/ui/client/shim.d.ts
Expand Up @@ -7,3 +7,9 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}

const __REPORT__: boolean

declare interface Window {
METADATA_PATH?: string
}
3 changes: 2 additions & 1 deletion packages/ui/index.html
Expand Up @@ -9,13 +9,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Readex+Pro:wght@300;400&display=swap" rel="stylesheet">
<script>
(function() {
(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
<!-- !LOAD_METADATA! -->
</head>
<body>
<div id="app"></div>
Expand Down
77 changes: 77 additions & 0 deletions packages/ui/node/reporter.ts
@@ -0,0 +1,77 @@
import { existsSync, promises as fs } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { basename, dirname, relative, resolve } from 'pathe'
import c from 'picocolors'
import fg from 'fast-glob'
import { stringify } from 'flatted'
// eslint-disable-next-line no-restricted-imports
import type { File, ModuleGraphData, Reporter, ResolvedConfig, Vitest } from 'vitest'
import { getModuleGraph } from '../../vitest/src/utils/graph'
import { getOutputFile } from '../../vitest/src/utils/config-helpers'

interface HTMLReportData {
paths: string[]
files: File[]
config: ResolvedConfig
moduleGraph: Record<string, ModuleGraphData>
}

const distDir = resolve(fileURLToPath(import.meta.url), '../../dist')

export default class HTMLReporter implements Reporter {
start = 0
ctx!: Vitest
reportUIPath!: string

async onInit(ctx: Vitest) {
this.ctx = ctx
this.start = Date.now()
}

async onFinished() {
const result: HTMLReportData = {
paths: await this.ctx.state.getPaths(),
files: this.ctx.state.getFiles(),
config: this.ctx.config,
moduleGraph: {},
}
await Promise.all(
result.files.map(async (file) => {
result.moduleGraph[file.filepath] = await getModuleGraph(this.ctx, file.filepath)
}),
)
await this.writeReport(stringify(result))
}

async writeReport(report: string) {
const htmlFile = getOutputFile(this.ctx.config, 'html') || 'html/index.html'
const htmlFileName = basename(htmlFile)
const htmlDir = resolve(this.ctx.config.root, dirname(htmlFile))

const metaFile = resolve(htmlDir, 'html.meta.json')

if (!existsSync(htmlDir))
await fs.mkdir(resolve(htmlDir, 'assets'), { recursive: true })

await fs.writeFile(metaFile, report, 'utf-8')
const ui = resolve(distDir, 'client')
// copy ui
const files = fg.sync('**/*', { cwd: ui })
await Promise.all(files.map(async (f) => {
if (f === 'index.html') {
const html = await fs.readFile(resolve(ui, f), 'utf-8')
const filePath = relative(htmlDir, metaFile)
await fs.writeFile(
resolve(htmlDir, htmlFileName),
html.replace('<!-- !LOAD_METADATA! -->', `<script>window.METADATA_PATH="${filePath}"</script>`),
)
}
else {
await fs.copyFile(resolve(ui, f), resolve(htmlDir, f))
}
}))

this.ctx.logger.log(`${c.bold(c.inverse(c.magenta(' HTML ')))} ${c.magenta('Report is generated')}`)
this.ctx.logger.log(`${c.dim(' You can run ')}${c.bold(`npx vite preview --base __vitest__ --outDir ${relative(this.ctx.config.root, htmlDir)}`)}${c.dim(' to see the test results.')}`)
}
}

0 comments on commit b8f34eb

Please sign in to comment.