UNPKG

@plugjs/cov8

Version:

V8 Coverage Plugin for the PlugJS Build System ==============================================

415 lines (339 loc) 13.9 kB
import { fileURLToPath, pathToFileURL } from 'node:url' import { assert } from '@plugjs/plug/asserts' import { readFile } from '@plugjs/plug/fs' import { $gry, $p } from '@plugjs/plug/logging' import { SourceMapConsumer } from 'source-map' import type { Logger } from '@plugjs/plug/logging' import type { AbsolutePath } from '@plugjs/plug/paths' import type { RawSourceMap } from 'source-map' /* ========================================================================== * * V8 COVERAGE TYPES * * ========================================================================== */ /** Coverage range */ export interface V8CoveredRange { /** The offset in the script of the first character covered */ startOffset: number, /** The offset (exclusive) in the script of the last character covered */ endOffset: number, /** The number of times the specified offset was covered */ count: number, } /** Coverage report per function as invoked by Node */ export interface V8CoveredFunction { /** The name of the function being covered */ functionName: string, /** A flag indicating whether fine-grained (precise) coverage is available */ isBlockCoverage: boolean, /** * The ranges covered. * * The first range indicates the whole function. */ ranges: V8CoveredRange[], } /** Coverage result for a particlar script as seen by Node */ export interface V8CoverageResult { /** The script ID, uniquely identifying the script within the Node process */ scriptId: string, /** The URL of the script (might not be unique, if the script is loaded multiple times) */ url: string, /** Per-function report of coverage */ functions: V8CoveredFunction[] } /** Cached source map for a coverage result */ export interface V8SourceMapCache { /** The line lengths (sans EOL) in the transpiled code */ lineLengths: number[], /** The source map associated with the transpiled code */ data: RawSourceMap | null, /** The url (if any) of the sourcemap, for resolving relative paths */ url: string | null, } /** The RAW coverage data as emitted by Node, parsed from JSON */ export interface V8CoverageData { /** * Coverage results, per script. * * The first element in the array describes the coverage for the whole script. */ 'result': V8CoverageResult[], /** Timestamp when coverage was taken */ 'timestamp'?: number, /** Source maps caches keyed by `result[?].url` */ 'source-map-cache'?: Record<string, V8SourceMapCache> } /* ========================================================================== * * COVERAGE ANALYSIS * * ========================================================================== */ /** * The bias for source map analisys (defaults to `least_upper_bound`). * * We use `least_upper_bound` here, as it's the _opposite_ of the default * `greatest_lower_bound`, and we _reverse_ the lookup of the sourcemaps (from * source code to generated code). */ export type SourceMapBias = 'greatest_lower_bound' | 'least_upper_bound' | 'none' | undefined /** Interface providing coverage data */ export interface CoverageAnalyser { /** Return the number of coverage passes for the given location */ coverage(source: string, line: number, column: number): number /** Destroy this instance */ destroy(): void } /* ========================================================================== */ /** Basic abstract class implementing the {@link CoverageAnalyser} class */ abstract class CoverageAnalyserImpl implements CoverageAnalyser { constructor(protected readonly _log: Logger) {} abstract init(): Promise<this> abstract destroy(): void abstract coverage(source: string, line: number, column: number): number } /* ========================================================================== */ /** Return coverage data from a V8 {@link V8CoverageResult} structure */ class CoverageResultAnalyser extends CoverageAnalyserImpl { /** Number of passes at each character in the result */ protected readonly _coverage: readonly (number | undefined)[] /** Internal private field for init/_lineLengths getter */ protected _lineLengths?: readonly number[] constructor( log: Logger, protected readonly _result: V8CoverageResult, ) { super(log) const _coverage: (number | undefined)[] = [] for (const coveredFunction of _result.functions) { for (const range of coveredFunction.ranges) { for (let i = range.startOffset; i < range.endOffset; i ++) { _coverage[i] = range.count } } } this._coverage = _coverage } async init(): Promise<this> { const filename = fileURLToPath(this._result.url) const source = await readFile(filename, 'utf-8') this._lineLengths = source.split('\n').map((line) => line.length) return this } destroy(): void { // Nothing to do } /** Return the number of coverage passes for the given location */ coverage(source: string, line: number, column: number): number { assert(this._lineLengths, 'Analyser not initialized') assert(source === this._result.url, `Wrong source ${source} (should be ${this._result.url})`) const { _lineLengths, _coverage } = this let offset = 0 /* Calculate the offset at the beginning of the line */ for (let l = line - 2; l >= 0; l--) offset += _lineLengths[l]! + 1 /* Return the number of passes from the coverage data */ return _coverage[offset + column] || 0 } } /* ========================================================================== */ /** Return coverage from a V8 {@link V8CoverageResult} with a sitemap */ class CoverageSitemapAnalyser extends CoverageResultAnalyser { private _preciseMappings = new Map<string, { line: number, column: number }>() private _sourceMap?: SourceMapConsumer constructor( log: Logger, result: V8CoverageResult, private readonly _sourceMapCache: V8SourceMapCache, private readonly _sourceMapBias: SourceMapBias, ) { super(log, result) this._lineLengths = _sourceMapCache.lineLengths } private _key(source: string, line: number, column: number): string { return `${line}:${column}:${source}` } async init(): Promise<this> { const sourceMap = this._sourceMapCache.data assert(sourceMap, 'Missing source map data from cache') this._sourceMap = await new SourceMapConsumer(sourceMap) if (this._sourceMapBias === 'none') { this._sourceMap.eachMapping((m) => { const location = { line: m.generatedLine, column: m.generatedColumn } const key = this._key(m.source, m.originalLine, m.originalColumn) this._preciseMappings.set(key, location) }) } return this } destroy(): void { this._sourceMap?.destroy() } coverage(source: string, line: number, column: number): number { assert(this._sourceMap, 'Analyser not initialized') if (this._sourceMapBias === 'none') { const key = this._key(source, line, column) const location = this._preciseMappings.get(key) if (! location) { this._log.debug(`No precise mapping for ${source}:${line}:${column}`) return 0 } else { return super.coverage(this._result.url, location.line, location.column) } } const bias = mapBias(this._sourceMapBias) const generated = this._sourceMap.generatedPositionFor({ source, line, column, bias }) /* coverage ignore if */ if (! generated) { this._log.debug(`No position generated for ${source}:${line}:${column}`) return 0 } /* coverage ignore if */ if (generated.line == null) { this._log.debug(`No line generated for ${source}:${line}:${column}`) return 0 } /* coverage ignore if */ if (generated.column == null) { this._log.debug(`No column generated for ${source}:${line}:${column}`) return 0 } return super.coverage(this._result.url, generated.line, generated.column) } } /* ========================================================================== */ function mapBias(bias?: 'greatest_lower_bound' | 'least_upper_bound'): number | undefined { if (bias === 'greatest_lower_bound') return SourceMapConsumer.GREATEST_LOWER_BOUND if (bias === 'least_upper_bound') return SourceMapConsumer.LEAST_UPPER_BOUND /* coverage ignore next */ return undefined } /** Combine (add) all coverage data from all analysers */ function combineCoverage( analysers: Set<CoverageAnalyser> | undefined, source: string, line: number, column: number, ): number { let coverage = 0 if (! analysers) return coverage for (const analyser of analysers) { coverage += analyser.coverage(source, line, column) } return coverage } /* ========================================================================== */ /** Associate one or more {@link CoverageAnalyser} with different sources */ export class SourcesCoverageAnalyser extends CoverageAnalyserImpl { private readonly _mappings = new Map<string, Set<CoverageAnalyserImpl>>() constructor(log: Logger, private readonly _filename: AbsolutePath) { super(log) } hasMappings(): boolean { return this._mappings.size > 0 } add(source: string, analyser: CoverageAnalyserImpl): void { const analysers = this._mappings.get(source) || new Set() analysers.add(analyser) this._mappings.set(source, analysers) } async init(): Promise<this> { this._log.debug('SourcesCoverageAnalyser', $p(this._filename), $gry(`(${this._mappings.size} mappings)`)) for (const analysers of this._mappings.values()) { for (const analyser of analysers) { await analyser.init() } } return this } destroy(): void { for (const analysers of this._mappings.values()) { for (const analyser of analysers) { analyser.destroy() } } } coverage(source: string, line: number, column: number): number { const analysers = this._mappings.get(source) return combineCoverage(analysers, source, line, column) } } /** Combine multiple {@link CoverageAnalyser} instances together */ export class CombiningCoverageAnalyser extends CoverageAnalyserImpl { private readonly _analysers = new Set<CoverageAnalyserImpl>() add(analyser: CoverageAnalyserImpl): void { this._analysers.add(analyser) } async init(): Promise<this> { this._log.debug('CombiningCoverageAnalyser', $gry(`(${this._analysers.size} analysers)`)) this._log.enter() try { for (const analyser of this._analysers) await analyser.init() return this } finally { this._log.leave() } } destroy(): void { for (const analyser of this._analysers) analyser.destroy() } coverage(source: string, line: number, column: number): number { return combineCoverage(this._analysers, source, line, column) } } /* ========================================================================== */ /** * Analyse coverage for the specified source files, using the data from the * specified coverage files and produce a {@link CoverageReport}. */ export async function createAnalyser( sourceFiles: AbsolutePath[], coverageFiles: AbsolutePath[], sourceMapBias: SourceMapBias, log: Logger, ): Promise<CoverageAnalyser> { /* Internally V8 coverage uses URLs for everything */ const urls = sourceFiles.map((path) => pathToFileURL(path).toString()) /* The coverage analyser combining all coverage files in the directory */ const analyser = new CombiningCoverageAnalyser(log) /* Resolve and walk the coverage directory, finding "coverage-*.json" files */ for await (const coverageFile of coverageFiles) { /* The "SourceCoverageAnalyser" for this coverage file */ const coverageFileAnalyser = new SourcesCoverageAnalyser(log, coverageFile) /* Parse our coverage file from JSON */ log.info('Parsing coverage file', $p(coverageFile)) const contents = await readFile(coverageFile, 'utf-8') const coverage: V8CoverageData = JSON.parse(contents) /* Let's look inside of the coverage file... */ for (const result of coverage.result) { if (!result.url.startsWith('node:')) { log.debug('Found coverage data for', result.url) } /* * Each coverage result (script) can be associated with a sitemap or * not... Sometimes (as in with ts-node) the sitemap simply points to * itself (same file), but embeds all the transformation information * between the file on disk, and what's been used by Node.JS. */ const mapping = coverage['source-map-cache']?.[result.url] if (mapping) { log.debug('Found source mapping for', result.url, mapping.data) /* * If we have mapping, we want to see if any of the sourcemap's source * files matches one of the sources we have to analyse. */ const matches = urls.filter((url) => mapping.data?.sources.includes(url)) /* If we map any file, we associate it with our source map analyser */ if (matches.length) { log.debug('Found source mapping matches', matches) const sourceAnalyser = new CoverageSitemapAnalyser(log, result, mapping, sourceMapBias) for (const match of matches) coverageFileAnalyser.add(match, sourceAnalyser) } /* * If we have no source map for the file, but it matches one of the * ones we have to analyse coverage for, we add that directly... */ } else if (urls.includes(result.url)) { coverageFileAnalyser.add(result.url, new CoverageResultAnalyser(log, result)) } } /* Add the analyser if it has some mappings */ if (coverageFileAnalyser.hasMappings()) analyser.add(coverageFileAnalyser) } return await analyser.init() }