@plugjs/cov8
Version:
V8 Coverage Plugin for the PlugJS Build System ==============================================
164 lines (134 loc) • 5.84 kB
text/typescript
import { sep } from 'node:path'
import { html, initFunction } from '@plugjs/cov8-html'
import { assert } from '@plugjs/plug/asserts'
import { Files } from '@plugjs/plug/files'
import { $gry, $ms, $p, $plur, $red, $ylw, ERROR, NOTICE, WARN } from '@plugjs/plug/logging'
import { resolveAbsolutePath } from '@plugjs/plug/paths'
import { walk } from '@plugjs/plug/utils'
import { createAnalyser } from './analysis'
import { coverageReport } from './report'
import type { AbsolutePath } from '@plugjs/plug/paths'
import type { Context, PipeParameters, Plug } from '@plugjs/plug/pipe'
import type { CoverageReportOptions } from './index'
import type { CoverageResult } from './report'
export class Coverage implements Plug<Files | undefined> {
constructor(...args: PipeParameters<'coverage'>)
constructor(
private readonly _coverageDir: string,
private readonly _options: Partial<CoverageReportOptions> = {},
) {}
async pipe(files: Files, context: Context): Promise<Files | undefined> {
const coverageDir = context.resolve(this._coverageDir)
const coverageFiles: AbsolutePath[] = []
for await (const file of walk(coverageDir, [ 'coverage-*.json' ])) {
coverageFiles.push(resolveAbsolutePath(coverageDir, file))
}
assert(coverageFiles.length > 0, `No coverage files found in ${$p(coverageDir)}`)
const sourceFiles = [ ...files.absolutePaths() ]
const ms1 = Date.now()
const analyser = await createAnalyser(
sourceFiles,
coverageFiles,
this._options.sourceMapBias || 'least_upper_bound',
context.log,
)
context.log.info('Parsed', coverageFiles.length, 'coverage files', $ms(Date.now() - ms1))
const ms2 = Date.now()
const report = await coverageReport(analyser, sourceFiles, context.log)
context.log.info('Analysed', sourceFiles.length, 'source files', $ms(Date.now() - ms2))
analyser.destroy()
const {
minimumCoverage = 50,
minimumFileCoverage = minimumCoverage,
optimalCoverage = Math.round((100 + minimumCoverage) / 2),
optimalFileCoverage = Math.round((100 + minimumFileCoverage) / 2),
} = this._options
let max = 0
for (const file in report) {
if (file.length > max) max = file.length
}
let maxLength = 0
for (const file in report.results) {
if (file.length > maxLength) maxLength = file.length
}
let fileErrors = 0
let fileWarnings = 0
const _report = context.log.report('Coverage report')
for (const [ _file, result ] of Object.entries(report.results)) {
const { coverage } = result.nodeCoverage
const file = _file as AbsolutePath
if (coverage == null) {
_report.annotate(NOTICE, file, 'n/a')
} else if (coverage < minimumFileCoverage) {
_report.annotate(ERROR, file, `${coverage} %`)
fileErrors ++
} else if (coverage < optimalFileCoverage) {
_report.annotate(WARN, file, `${coverage} %`)
fileWarnings ++
} else {
_report.annotate(NOTICE, file, `${coverage} %`)
}
}
/* coverage ignore if */
if (report.nodes.coverage == null) {
const message = 'No coverage data collected'
_report.add({ level: WARN, message })
} else if (report.nodes.coverage < minimumCoverage) {
const message = `${$red(`${report.nodes.coverage}%`)} does not meet minimum coverage ${$gry(`(${minimumCoverage}%)`)}`
_report.add({ level: ERROR, message })
} else if (report.nodes.coverage < optimalCoverage) {
const message = `${$ylw(`${report.nodes.coverage}%`)} does not meet optimal coverage ${$gry(`(${optimalCoverage}%)`)}`
_report.add({ level: WARN, message })
}
if (fileErrors) {
/* coverage ignore next */
const f = $plur(fileErrors, 'file does', 'files do')
const message = `${f} not meet minimum file coverage ${$gry(`(${minimumFileCoverage}%)`)}`
_report.add({ level: ERROR, message })
}
if (fileWarnings) {
/* coverage ignore next */
const f = $plur(fileWarnings, 'file does', 'files do')
const message = `${f} not meet optimal file coverage ${$gry(`(${optimalFileCoverage}%)`)}`
_report.add({ level: WARN, message })
}
/* If we don't have to write a report, pass-through the coverage files */
if (this._options.reportDir == null) return _report.done(false) as any
/* Create a builder to emit our reports */
const reportDir = context.resolve(this._options.reportDir)
const builder = Files.builder(reportDir)
/* Thresholds to inject in the report */
const date = new Date().toISOString()
const thresholds = {
minimumCoverage,
minimumFileCoverage,
optimalCoverage,
optimalFileCoverage,
}
/* The JSON file in the report has *absolute* file paths */
await builder.write('report.json', JSON.stringify({ ...report, thresholds, date }))
/* The HTML file rendering our report */
await builder.write('index.html', html)
/* The JSONP file (for our HTML report) has relative files and a tree */
const results: Record<string, CoverageResult> = {}
for (const [ rel, abs ] of files.pathMappings()) {
results[rel] = report.results[abs]!
}
const tree: Record<string, any> = {}
for (const relative of Object.keys(results)) {
const directories = relative.split(sep)
const file = directories.pop()!
let node = tree
for (const dir of directories) {
node = node[dir] = node[dir] || {}
}
node[file] = relative
}
const jsonp = JSON.stringify({ ...report, results, thresholds, tree, date })
await builder.write('report.js', `${initFunction}(${jsonp});`)
/* Emit our coverage report */
_report.done(false)
/* Return emitted files */
return builder.build() as any
}
}