vitest/packages/ui/node/reporter.ts
Ari Perkkiö 149f8e5095
fix(reporter)!: remove deprecated APIs (#8223)
Co-authored-by: Vladimir Sheremet <sleuths.slews0s@icloud.com>
2025-08-04 14:30:28 +02:00

206 lines
6.4 KiB
TypeScript

import type { Task, TestAttachment } from '@vitest/runner'
import type { ModuleGraphData, RunnerTestFile, SerializedConfig } from 'vitest'
import type { HTMLOptions, Vitest } from 'vitest/node'
import type { Reporter } from 'vitest/reporters'
import crypto from 'node:crypto'
import { promises as fs } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { gzip, constants as zlibConstants } from 'node:zlib'
import { stringify } from 'flatted'
import mime from 'mime/lite'
import { dirname, extname, relative, resolve } from 'pathe'
import { globSync } from 'tinyglobby'
import c from 'tinyrainbow'
import { getModuleGraph } from '../../vitest/src/utils/graph'
interface PotentialConfig {
outputFile?: string | Partial<Record<string, string>>
}
function getOutputFile(config: PotentialConfig | undefined) {
if (!config?.outputFile) {
return
}
if (typeof config.outputFile === 'string') {
return config.outputFile
}
return config.outputFile.html
}
interface HTMLReportData {
paths: string[]
files: RunnerTestFile[]
config: SerializedConfig
projects: string[]
moduleGraph: Record<string, Record<string, ModuleGraphData>>
unhandledErrors: unknown[]
// filename -> source
sources: Record<string, string>
}
const distDir = resolve(fileURLToPath(import.meta.url), '../../dist')
export default class HTMLReporter implements Reporter {
start = 0
ctx!: Vitest
options: HTMLOptions
private reporterDir!: string
private htmlFilePath!: string
constructor(options: HTMLOptions) {
this.options = options
}
async onInit(ctx: Vitest): Promise<void> {
this.ctx = ctx
this.start = Date.now()
const htmlFile
= this.options.outputFile
|| getOutputFile(this.ctx.config)
|| 'html/index.html'
const htmlFilePath = resolve(this.ctx.config.root, htmlFile)
this.reporterDir = dirname(htmlFilePath)
this.htmlFilePath = htmlFilePath
await fs.mkdir(resolve(this.reporterDir, 'data'), { recursive: true })
await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true })
}
async onTestRunEnd(): Promise<void> {
const result: HTMLReportData = {
paths: this.ctx.state.getPaths(),
files: this.ctx.state.getFiles(),
config: this.ctx.getRootProject().serializedConfig,
unhandledErrors: this.ctx.state.getUnhandledErrors(),
projects: this.ctx.projects.map(p => p.name),
moduleGraph: {},
sources: {},
}
const promises: Promise<void>[] = []
const processAttachments = (task: Task) => {
if (task.type === 'test') {
task.annotations.forEach((annotation) => {
const attachment = annotation.attachment
if (attachment) {
promises.push(this.processAttachment(attachment))
}
})
}
else {
task.tasks.forEach(processAttachments)
}
}
promises.push(...result.files.map(async (file) => {
processAttachments(file)
const projectName = file.projectName || ''
const resolvedConfig = this.ctx.getProjectByName(projectName).config
const browser = resolvedConfig.browser.enabled && resolvedConfig.browser.ui
result.moduleGraph[projectName] ??= {}
result.moduleGraph[projectName][file.filepath] = await getModuleGraph(
this.ctx,
projectName,
file.filepath,
browser,
)
if (!result.sources[file.filepath]) {
try {
result.sources[file.filepath] = await fs.readFile(file.filepath, {
encoding: 'utf-8',
})
}
catch {
// just ignore
}
}
}))
await Promise.all(promises)
await this.writeReport(stringify(result))
}
async processAttachment(attachment: TestAttachment): Promise<void> {
if (attachment.path) {
// keep external resource as is, but remove body if it's set somehow
if (
attachment.path.startsWith('http://')
|| attachment.path.startsWith('https://')
) {
attachment.body = undefined
return
}
const buffer = await readFile(attachment.path)
const hash = crypto.createHash('sha1').update(buffer).digest('hex')
const filename = hash + extname(attachment.path)
// move the file into an html directory to make access/publishing UI easier
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
attachment.path = filename
attachment.body = undefined
return
}
if (attachment.body) {
const buffer = typeof attachment.body === 'string'
? Buffer.from(attachment.body, 'base64')
: Buffer.from(attachment.body)
const hash = crypto.createHash('sha1').update(buffer).digest('hex')
const extension = mime.getExtension(attachment.contentType || 'application/octet-stream') || 'dat'
const filename = `${hash}.${extension}`
// store the file in html directory instead of passing down as a body
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
attachment.path = filename
attachment.body = undefined
}
}
async writeReport(report: string): Promise<void> {
const metaFile = resolve(this.reporterDir, 'html.meta.json.gz')
const promiseGzip = promisify(gzip)
const data = await promiseGzip(report, {
level: zlibConstants.Z_BEST_COMPRESSION,
})
await fs.writeFile(metaFile, data, 'base64')
const ui = resolve(distDir, 'client')
// copy ui
const files = globSync(['**/*'], { cwd: ui, expandDirectories: false })
await Promise.all(
files.map(async (f) => {
if (f === 'index.html') {
const html = await fs.readFile(resolve(ui, f), 'utf-8')
const filePath = relative(this.reporterDir, metaFile)
await fs.writeFile(
this.htmlFilePath,
html.replace(
'<!-- !LOAD_METADATA! -->',
`<script>window.METADATA_PATH="${filePath}"</script>`,
),
)
}
else {
await fs.copyFile(resolve(ui, f), resolve(this.reporterDir, 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 --outDir ${relative(this.ctx.config.root, this.reporterDir)}`,
)}${c.dim(' to see the test results.')}`,
)
}
}