@plugjs/cov8 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ import { fileURLToPath, pathToFileURL } from 'node:url'
2
+
3
+ import { assert } from '@plugjs/plug/asserts'
4
+ import { readFile } from '@plugjs/plug/fs'
5
+ import { $gry, $p } from '@plugjs/plug/logging'
6
+ import { SourceMapConsumer } from 'source-map'
7
+
8
+ import type { Logger } from '@plugjs/plug/logging'
9
+ import type { AbsolutePath } from '@plugjs/plug/paths'
10
+ import type { RawSourceMap } from 'source-map'
11
+
12
+ /* ========================================================================== *
13
+ * V8 COVERAGE TYPES *
14
+ * ========================================================================== */
15
+
16
+ /** Coverage range */
17
+ export interface V8CoveredRange {
18
+ /** The offset in the script of the first character covered */
19
+ startOffset: number,
20
+ /** The offset (exclusive) in the script of the last character covered */
21
+ endOffset: number,
22
+ /** The number of times the specified offset was covered */
23
+ count: number,
24
+ }
25
+
26
+ /** Coverage report per function as invoked by Node */
27
+ export interface V8CoveredFunction {
28
+ /** The name of the function being covered */
29
+ functionName: string,
30
+ /** A flag indicating whether fine-grained (precise) coverage is available */
31
+ isBlockCoverage: boolean,
32
+ /**
33
+ * The ranges covered.
34
+ *
35
+ * The first range indicates the whole function.
36
+ */
37
+ ranges: V8CoveredRange[],
38
+ }
39
+
40
+ /** Coverage result for a particlar script as seen by Node */
41
+ export interface V8CoverageResult {
42
+ /** The script ID, uniquely identifying the script within the Node process */
43
+ scriptId: string,
44
+ /** The URL of the script (might not be unique, if the script is loaded multiple times) */
45
+ url: string,
46
+ /** Per-function report of coverage */
47
+ functions: V8CoveredFunction[]
48
+ }
49
+
50
+ /** Cached source map for a coverage result */
51
+ export interface V8SourceMapCache {
52
+ /** The line lengths (sans EOL) in the transpiled code */
53
+ lineLengths: number[],
54
+ /** The source map associated with the transpiled code */
55
+ data: RawSourceMap | null,
56
+ /** The url (if any) of the sourcemap, for resolving relative paths */
57
+ url: string | null,
58
+ }
59
+
60
+ /** The RAW coverage data as emitted by Node, parsed from JSON */
61
+ export interface V8CoverageData {
62
+ /**
63
+ * Coverage results, per script.
64
+ *
65
+ * The first element in the array describes the coverage for the whole script.
66
+ */
67
+ 'result': V8CoverageResult[],
68
+ /** Timestamp when coverage was taken */
69
+ 'timestamp'?: number,
70
+ /** Source maps caches keyed by `result[?].url` */
71
+ 'source-map-cache'?: Record<string, V8SourceMapCache>
72
+ }
73
+
74
+ /* ========================================================================== *
75
+ * COVERAGE ANALYSIS *
76
+ * ========================================================================== */
77
+
78
+ /**
79
+ * The bias for source map analisys (defaults to `least_upper_bound`).
80
+ *
81
+ * We use `least_upper_bound` here, as it's the _opposite_ of the default
82
+ * `greatest_lower_bound`, and we _reverse_ the lookup of the sourcemaps (from
83
+ * source code to generated code).
84
+ */
85
+ export type SourceMapBias = 'greatest_lower_bound' | 'least_upper_bound' | 'none' | undefined
86
+
87
+ /** Interface providing coverage data */
88
+ export interface CoverageAnalyser {
89
+ /** Return the number of coverage passes for the given location */
90
+ coverage(source: string, line: number, column: number): number
91
+ /** Destroy this instance */
92
+ destroy(): void
93
+ }
94
+
95
+ /* ========================================================================== */
96
+
97
+ /** Basic abstract class implementing the {@link CoverageAnalyser} class */
98
+ abstract class CoverageAnalyserImpl implements CoverageAnalyser {
99
+ constructor(protected readonly _log: Logger) {}
100
+
101
+ abstract init(): Promise<this>
102
+ abstract destroy(): void
103
+ abstract coverage(source: string, line: number, column: number): number
104
+ }
105
+
106
+
107
+ /* ========================================================================== */
108
+
109
+ /** Return coverage data from a V8 {@link V8CoverageResult} structure */
110
+ class CoverageResultAnalyser extends CoverageAnalyserImpl {
111
+ /** Number of passes at each character in the result */
112
+ protected readonly _coverage: readonly (number | undefined)[]
113
+ /** Internal private field for init/_lineLengths getter */
114
+ protected _lineLengths?: readonly number[]
115
+
116
+ constructor(
117
+ log: Logger,
118
+ protected readonly _result: V8CoverageResult,
119
+ ) {
120
+ super(log)
121
+
122
+ const _coverage: (number | undefined)[] = []
123
+
124
+ for (const coveredFunction of _result.functions) {
125
+ for (const range of coveredFunction.ranges) {
126
+ for (let i = range.startOffset; i < range.endOffset; i ++) {
127
+ _coverage[i] = range.count
128
+ }
129
+ }
130
+ }
131
+
132
+ this._coverage = _coverage
133
+ }
134
+
135
+ async init(): Promise<this> {
136
+ const filename = fileURLToPath(this._result.url)
137
+ const source = await readFile(filename, 'utf-8')
138
+ this._lineLengths = source.split('\n').map((line) => line.length)
139
+ return this
140
+ }
141
+
142
+ destroy(): void {
143
+ // Nothing to do
144
+ }
145
+
146
+ /** Return the number of coverage passes for the given location */
147
+ coverage(source: string, line: number, column: number): number {
148
+ assert(this._lineLengths, 'Analyser not initialized')
149
+ assert(source === this._result.url, `Wrong source ${source} (should be ${this._result.url})`)
150
+
151
+ const { _lineLengths, _coverage } = this
152
+ let offset = 0
153
+
154
+ /* Calculate the offset at the beginning of the line */
155
+ for (let l = line - 2; l >= 0; l--) offset += _lineLengths[l]! + 1
156
+
157
+ /* Return the number of passes from the coverage data */
158
+ return _coverage[offset + column] || 0
159
+ }
160
+ }
161
+
162
+ /* ========================================================================== */
163
+
164
+ /** Return coverage from a V8 {@link V8CoverageResult} with a sitemap */
165
+ class CoverageSitemapAnalyser extends CoverageResultAnalyser {
166
+ private _preciseMappings = new Map<string, { line: number, column: number }>()
167
+ private _sourceMap?: SourceMapConsumer
168
+
169
+ constructor(
170
+ log: Logger,
171
+ result: V8CoverageResult,
172
+ private readonly _sourceMapCache: V8SourceMapCache,
173
+ private readonly _sourceMapBias: SourceMapBias,
174
+ ) {
175
+ super(log, result)
176
+ this._lineLengths = _sourceMapCache.lineLengths
177
+ }
178
+
179
+ private _key(source: string, line: number, column: number): string {
180
+ return `${line}:${column}:${source}`
181
+ }
182
+
183
+ async init(): Promise<this> {
184
+ const sourceMap = this._sourceMapCache.data
185
+ assert(sourceMap, 'Missing source map data from cache')
186
+ this._sourceMap = await new SourceMapConsumer(sourceMap)
187
+
188
+ if (this._sourceMapBias === 'none') {
189
+ this._sourceMap.eachMapping((m) => {
190
+ const location = { line: m.generatedLine, column: m.generatedColumn }
191
+ const key = this._key(m.source, m.originalLine, m.originalColumn)
192
+ this._preciseMappings.set(key, location)
193
+ })
194
+ }
195
+
196
+ return this
197
+ }
198
+
199
+ destroy(): void {
200
+ this._sourceMap?.destroy()
201
+ }
202
+
203
+ coverage(source: string, line: number, column: number): number {
204
+ assert(this._sourceMap, 'Analyser not initialized')
205
+
206
+ if (this._sourceMapBias === 'none') {
207
+ const key = this._key(source, line, column)
208
+ const location = this._preciseMappings.get(key)
209
+ if (! location) {
210
+ this._log.debug(`No precise mapping for ${source}:${line}:${column}`)
211
+ return 0
212
+ } else {
213
+ return super.coverage(this._result.url, location.line, location.column)
214
+ }
215
+ }
216
+
217
+ const bias =
218
+ this._sourceMapBias === 'greatest_lower_bound' ? SourceMapConsumer.GREATEST_LOWER_BOUND :
219
+ this._sourceMapBias === 'least_upper_bound' ? SourceMapConsumer.LEAST_UPPER_BOUND :
220
+ /* coverage ignore next */ undefined
221
+
222
+ const generated = this._sourceMap.generatedPositionFor({ source, line, column, bias })
223
+
224
+ /* coverage ignore if */
225
+ if (! generated) {
226
+ this._log.debug(`No position generated for ${source}:${line}:${column}`)
227
+ return 0
228
+ }
229
+
230
+ /* coverage ignore if */
231
+ if (generated.line == null) {
232
+ this._log.debug(`No line generated for ${source}:${line}:${column}`)
233
+ return 0
234
+ }
235
+
236
+ /* coverage ignore if */
237
+ if (generated.column == null) {
238
+ this._log.debug(`No column generated for ${source}:${line}:${column}`)
239
+ return 0
240
+ }
241
+
242
+ return super.coverage(this._result.url, generated.line, generated.column)
243
+ }
244
+ }
245
+
246
+ /* ========================================================================== */
247
+
248
+ /** Combine (add) all coverage data from all analysers */
249
+ function combineCoverage(
250
+ analysers: Set<CoverageAnalyser> | undefined,
251
+ source: string,
252
+ line: number,
253
+ column: number,
254
+ ): number {
255
+ let coverage = 0
256
+
257
+ if (! analysers) return coverage
258
+
259
+ for (const analyser of analysers) {
260
+ coverage += analyser.coverage(source, line, column)
261
+ }
262
+
263
+ return coverage
264
+ }
265
+
266
+ /* ========================================================================== */
267
+
268
+ /** Associate one or more {@link CoverageAnalyser} with different sources */
269
+ export class SourcesCoverageAnalyser extends CoverageAnalyserImpl {
270
+ private readonly _mappings = new Map<string, Set<CoverageAnalyserImpl>>()
271
+
272
+ constructor(log: Logger, private readonly _filename: AbsolutePath) {
273
+ super(log)
274
+ }
275
+
276
+ hasMappings(): boolean {
277
+ return this._mappings.size > 0
278
+ }
279
+
280
+ add(source: string, analyser: CoverageAnalyserImpl): void {
281
+ const analysers = this._mappings.get(source) || new Set()
282
+ analysers.add(analyser)
283
+ this._mappings.set(source, analysers)
284
+ }
285
+
286
+ async init(): Promise<this> {
287
+ this._log.debug('SourcesCoverageAnalyser', $p(this._filename), $gry(`(${this._mappings.size} mappings)`))
288
+ for (const analysers of this._mappings.values()) {
289
+ for (const analyser of analysers) {
290
+ await analyser.init()
291
+ }
292
+ }
293
+ return this
294
+ }
295
+
296
+ destroy(): void {
297
+ for (const analysers of this._mappings.values()) {
298
+ for (const analyser of analysers) {
299
+ analyser.destroy()
300
+ }
301
+ }
302
+ }
303
+
304
+ coverage(source: string, line: number, column: number): number {
305
+ const analysers = this._mappings.get(source)
306
+ return combineCoverage(analysers, source, line, column)
307
+ }
308
+ }
309
+
310
+ /** Combine multiple {@link CoverageAnalyser} instances together */
311
+ export class CombiningCoverageAnalyser extends CoverageAnalyserImpl {
312
+ private readonly _analysers = new Set<CoverageAnalyserImpl>()
313
+
314
+ add(analyser: CoverageAnalyserImpl): void {
315
+ this._analysers.add(analyser)
316
+ }
317
+
318
+ async init(): Promise<this> {
319
+ this._log.debug('CombiningCoverageAnalyser', $gry(`(${this._analysers.size} analysers)`))
320
+ this._log.enter()
321
+ try {
322
+ for (const analyser of this._analysers) await analyser.init()
323
+ return this
324
+ } finally {
325
+ this._log.leave()
326
+ }
327
+ }
328
+
329
+ destroy(): void {
330
+ for (const analyser of this._analysers) analyser.destroy()
331
+ }
332
+
333
+ coverage(source: string, line: number, column: number): number {
334
+ return combineCoverage(this._analysers, source, line, column)
335
+ }
336
+ }
337
+
338
+ /* ========================================================================== */
339
+
340
+ /**
341
+ * Analyse coverage for the specified source files, using the data from the
342
+ * specified coverage files and produce a {@link CoverageReport}.
343
+ */
344
+ export async function createAnalyser(
345
+ sourceFiles: AbsolutePath[],
346
+ coverageFiles: AbsolutePath[],
347
+ sourceMapBias: SourceMapBias,
348
+ log: Logger,
349
+ ): Promise<CoverageAnalyser> {
350
+ /* Internally V8 coverage uses URLs for everything */
351
+ const urls = sourceFiles.map((path) => pathToFileURL(path).toString())
352
+
353
+ /* The coverage analyser combining all coverage files in the directory */
354
+ const analyser = new CombiningCoverageAnalyser(log)
355
+
356
+ /* Resolve and walk the coverage directory, finding "coverage-*.json" files */
357
+ for await (const coverageFile of coverageFiles) {
358
+ /* The "SourceCoverageAnalyser" for this coverage file */
359
+ const coverageFileAnalyser = new SourcesCoverageAnalyser(log, coverageFile)
360
+
361
+ /* Parse our coverage file from JSON */
362
+ log.info('Parsing coverage file', $p(coverageFile))
363
+ const contents = await readFile(coverageFile, 'utf-8')
364
+ const coverage: V8CoverageData = JSON.parse(contents)
365
+
366
+ /* Let's look inside of the coverage file... */
367
+ for (const result of coverage.result) {
368
+ if (!result.url.startsWith('node:')) {
369
+ log.debug('Found coverage data for', result.url)
370
+ }
371
+
372
+ /*
373
+ * Each coverage result (script) can be associated with a sitemap or
374
+ * not... Sometimes (as in with ts-node) the sitemap simply points to
375
+ * itself (same file), but embeds all the transformation information
376
+ * between the file on disk, and what's been used by Node.JS.
377
+ */
378
+ const mapping = coverage['source-map-cache']?.[result.url]
379
+ if (mapping) {
380
+ log.debug('Found source mapping for', result.url, mapping.data)
381
+
382
+ /*
383
+ * If we have mapping, we want to see if any of the sourcemap's source
384
+ * files matches one of the sources we have to analyse.
385
+ */
386
+ const matches = urls.filter((url) => mapping.data?.sources.includes(url))
387
+
388
+ /* If we map any file, we associate it with our source map analyser */
389
+ if (matches.length) {
390
+ log.debug('Found source mapping matches', matches)
391
+
392
+ const sourceAnalyser = new CoverageSitemapAnalyser(log, result, mapping, sourceMapBias)
393
+ for (const match of matches) coverageFileAnalyser.add(match, sourceAnalyser)
394
+ }
395
+
396
+ /*
397
+ * If we have no source map for the file, but it matches one of the
398
+ * ones we have to analyse coverage for, we add that directly...
399
+ */
400
+ } else if (urls.includes(result.url)) {
401
+ coverageFileAnalyser.add(result.url, new CoverageResultAnalyser(log, result))
402
+ }
403
+ }
404
+
405
+ /* Add the analyser if it has some mappings */
406
+ if (coverageFileAnalyser.hasMappings()) analyser.add(coverageFileAnalyser)
407
+ }
408
+
409
+ return await analyser.init()
410
+ }
@@ -0,0 +1,164 @@
1
+ // Reference ourselves, so that the constructor's parameters are correct
2
+ /// <reference path="./index.ts"/>
3
+
4
+ import { sep } from 'node:path'
5
+
6
+ import { html, initFunction } from '@plugjs/cov8-html'
7
+ import { Files } from '@plugjs/plug/files'
8
+ import { $gry, $ms, $p, $red, $ylw, ERROR, NOTICE, WARN } from '@plugjs/plug/logging'
9
+ import { resolveAbsolutePath } from '@plugjs/plug/paths'
10
+ import { walk } from '@plugjs/plug/utils'
11
+
12
+ import { createAnalyser } from './analysis'
13
+ import { coverageReport } from './report'
14
+
15
+ import type { AbsolutePath } from '@plugjs/plug/paths'
16
+ import type { Context, PipeParameters, Plug } from '@plugjs/plug/pipe'
17
+ import type { CoverageReportOptions } from './index'
18
+ import type { CoverageResult } from './report'
19
+
20
+ export class Coverage implements Plug<Files | undefined> {
21
+ constructor(...args: PipeParameters<'coverage'>)
22
+ constructor(
23
+ private readonly _coverageDir: string,
24
+ private readonly _options: Partial<CoverageReportOptions> = {},
25
+ ) {}
26
+
27
+ async pipe(files: Files, context: Context): Promise<Files | undefined> {
28
+ const coverageDir = context.resolve(this._coverageDir)
29
+ const coverageFiles: AbsolutePath[] = []
30
+ for await (const file of walk(coverageDir, [ 'coverage-*.json' ])) {
31
+ coverageFiles.push(resolveAbsolutePath(coverageDir, file))
32
+ }
33
+
34
+ if (coverageFiles.length === 0) {
35
+ throw context.log.fail(`No coverage files found in ${$p(coverageDir)}`)
36
+ }
37
+
38
+ const sourceFiles = [ ...files.absolutePaths() ]
39
+
40
+ const ms1 = Date.now()
41
+ const analyser = await createAnalyser(
42
+ sourceFiles,
43
+ coverageFiles,
44
+ this._options.sourceMapBias || 'least_upper_bound',
45
+ context.log,
46
+ )
47
+ context.log.info('Parsed', coverageFiles.length, 'coverage files', $ms(Date.now() - ms1))
48
+
49
+ const ms2 = Date.now()
50
+ const report = await coverageReport(analyser, sourceFiles, context.log)
51
+ context.log.info('Analysed', sourceFiles.length, 'source files', $ms(Date.now() - ms2))
52
+
53
+ analyser.destroy()
54
+
55
+ const {
56
+ minimumCoverage = 50,
57
+ minimumFileCoverage = minimumCoverage,
58
+ optimalCoverage = Math.round((100 + minimumCoverage) / 2),
59
+ optimalFileCoverage = Math.round((100 + minimumFileCoverage) / 2),
60
+ } = this._options
61
+
62
+ let max = 0
63
+ for (const file in report) {
64
+ if (file.length > max) max = file.length
65
+ }
66
+
67
+ let maxLength = 0
68
+ for (const file in report.results) {
69
+ if (file.length > maxLength) maxLength = file.length
70
+ }
71
+
72
+ let fileErrors = 0
73
+ let fileWarnings = 0
74
+ const _report = context.log.report('Coverage report')
75
+
76
+ for (const [ _file, result ] of Object.entries(report.results)) {
77
+ const { coverage } = result.nodeCoverage
78
+ const file = _file as AbsolutePath
79
+
80
+ if (coverage == null) {
81
+ _report.annotate(NOTICE, file, 'n/a')
82
+ } else if (coverage < minimumFileCoverage) {
83
+ _report.annotate(ERROR, file, `${coverage} %`)
84
+ fileErrors ++
85
+ } else if (coverage < optimalFileCoverage) {
86
+ _report.annotate(WARN, file, `${coverage} %`)
87
+ fileWarnings ++
88
+ } else {
89
+ _report.annotate(NOTICE, file, `${coverage} %`)
90
+ }
91
+ }
92
+
93
+ /* coverage ignore if */
94
+ if (report.nodes.coverage == null) {
95
+ const message = 'No coverage data collected'
96
+ _report.add({ level: WARN, message })
97
+ } else if (report.nodes.coverage < minimumCoverage) {
98
+ const message = `${$red(`${report.nodes.coverage}%`)} does not meet minimum coverage ${$gry(`(${minimumCoverage}%)`)}`
99
+ _report.add({ level: ERROR, message })
100
+ } else if (report.nodes.coverage < optimalCoverage) {
101
+ const message = `${$ylw(`${report.nodes.coverage}%`)} does not meet optimal coverage ${$gry(`(${optimalCoverage}%)`)}`
102
+ _report.add({ level: WARN, message })
103
+ }
104
+
105
+ if (fileErrors) {
106
+ const message = `${$red(fileErrors)} files do not meet minimum file coverage ${$gry(`(${minimumFileCoverage}%)`)}`
107
+ _report.add({ level: ERROR, message })
108
+ }
109
+ if (fileWarnings) {
110
+ const message = `${$ylw(fileWarnings)} files do not meet optimal file coverage ${$gry(`(${optimalFileCoverage}%)`)}`
111
+ _report.add({ level: WARN, message })
112
+ }
113
+
114
+ /* If we don't have to write a report, pass-through the coverage files */
115
+ if (this._options.reportDir == null) return _report.done(false) as any
116
+
117
+ /* Create a builder to emit our reports */
118
+ const reportDir = context.resolve(this._options.reportDir)
119
+ const builder = Files.builder(reportDir)
120
+
121
+ /* Thresholds to inject in the report */
122
+ const date = new Date().toISOString()
123
+ const thresholds = {
124
+ minimumCoverage,
125
+ minimumFileCoverage,
126
+ optimalCoverage,
127
+ optimalFileCoverage,
128
+ }
129
+
130
+ /* The JSON file in the report has *absolute* file paths */
131
+ await builder.write('report.json', JSON.stringify({ ...report, thresholds, date }))
132
+
133
+ /* The HTML file rendering our report */
134
+ await builder.write('index.html', html)
135
+
136
+ /* The JSONP file (for our HTML report) has relative files and a tree */
137
+ const results: Record<string, CoverageResult> = {}
138
+ for (const [ rel, abs ] of files.pathMappings()) {
139
+ results[rel] = report.results[abs]!
140
+ }
141
+
142
+ const tree: Record<string, any> = {}
143
+ for (const relative of Object.keys(results)) {
144
+ const directories = relative.split(sep)
145
+ const file = directories.pop()!
146
+
147
+ let node = tree
148
+ for (const dir of directories) {
149
+ node = node[dir] = node[dir] || {}
150
+ }
151
+
152
+ node[file] = relative
153
+ }
154
+
155
+ const jsonp = JSON.stringify({ ...report, results, thresholds, tree, date })
156
+ await builder.write('report.js', `${initFunction}(${jsonp});`)
157
+
158
+ /* Emit our coverage report */
159
+ _report.done(false)
160
+
161
+ /* Return emitted files */
162
+ return builder.build() as any
163
+ }
164
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { install } from '@plugjs/plug/pipe'
2
+
3
+ import { Coverage } from './coverage'
4
+
5
+ import type { SourceMapBias } from './analysis'
6
+
7
+ /** Options to analyse coverage reports */
8
+ export interface CoverageOptions {
9
+ /** The bias for source map analisys (defaults to `greatest_lower_bound`) */
10
+ sourceMapBias?: SourceMapBias
11
+ /** Minimum _overall_ coverage (as a percentage, defaults to 50) */
12
+ minimumCoverage?: number,
13
+ /** Optimal _overall_ coverage (as a percentage, defaults to 50) */
14
+ optimalCoverage?: number,
15
+ /** Minimum _per-file_ coverage (as a percentage, defaults to 75) */
16
+ minimumFileCoverage?: number,
17
+ /** Optimal _per-file_ coverage (as a percentage, defaults to 75) */
18
+ optimalFileCoverage?: number,
19
+ }
20
+
21
+ export interface CoverageReportOptions extends CoverageOptions {
22
+ /** If specified, a JSON and HTML report will be written to this directory */
23
+ reportDir: string,
24
+ }
25
+
26
+ declare module '@plugjs/plug' {
27
+ export interface Pipe {
28
+ /**
29
+ * Analyse coverage using files generated by V8/NodeJS.
30
+ *
31
+ * @param coverageDir The directory where the `coverage-XXX.json` files
32
+ * generated by V8/NodeJS can be found.
33
+ */
34
+ coverage(coverageDir: string): Promise<undefined>
35
+ /**
36
+ * Analyse coverage using files generated by V8/NodeJS.
37
+ *
38
+ * @param coverageDir The directory where the `coverage-XXX.json` files
39
+ * generated by V8/NodeJS can be found.
40
+ * @param options Extra {@link CoverageOptions | options} allowing to
41
+ * specify coverage thresholds.
42
+ */
43
+ coverage(coverageDir: string, options: CoverageOptions): Promise<undefined>
44
+ /**
45
+ * Analyse coverage using files generated by V8/NodeJS and produce an HTML
46
+ * report in the directory specified in `options`.
47
+ *
48
+ * @param coverageDir The directory where the `coverage-XXX.json` files
49
+ * generated by V8/NodeJS can be found.
50
+ * @param options Extra {@link CoverageOptions | options} allowing to
51
+ * specify coverage thresholds where the HTML report should
52
+ * be written to.
53
+ */
54
+ coverage(coverageDir: string, options: CoverageReportOptions): Pipe
55
+ }
56
+ }
57
+
58
+ /* ========================================================================== *
59
+ * INSTALLATION / IMPLEMENTATION *
60
+ * ========================================================================== */
61
+
62
+ install('coverage', Coverage)