1 | // @ts-check
|
2 |
|
3 | import v8Coverage from "@bcoe/v8-coverage";
|
4 | import fs from "fs";
|
5 | import { join } from "path";
|
6 | import { fileURLToPath } from "url";
|
7 |
|
8 | import sourceRange from "./sourceRange.mjs";
|
9 |
|
10 | /**
|
11 | * Analyzes
|
12 | * [Node.js generated V8 JavaScript code coverage data](https://nodejs.org/api/cli.html#cli_node_v8_coverage_dir)
|
13 | * in a directory; useful for reporting.
|
14 | * @param {string} coverageDirPath Code coverage data directory path.
|
15 | * @returns {Promise<CoverageAnalysis>} Resolves the coverage analysis.
|
16 | */
|
17 | export default async function analyseCoverage(coverageDirPath) {
|
18 | if (typeof coverageDirPath !== "string")
|
19 | throw new TypeError("Argument 1 `coverageDirPath` must be a string.");
|
20 |
|
21 | const coverageDirFileNames = await fs.promises.readdir(coverageDirPath);
|
22 | const filteredProcessCoverages = [];
|
23 |
|
24 | for (const fileName of coverageDirFileNames)
|
25 | if (fileName.startsWith("coverage-"))
|
26 | filteredProcessCoverages.push(
|
27 | fs.promises
|
28 | .readFile(join(coverageDirPath, fileName), "utf8")
|
29 | .then((coverageFileJson) => {
|
30 | /** @type {import("@bcoe/v8-coverage").ProcessCov} */
|
31 | const { result } = JSON.parse(coverageFileJson);
|
32 | return {
|
33 | // For performance, filtering happens as early as possible.
|
34 | result: result.filter(
|
35 | ({ url }) =>
|
36 | // Exclude Node.js internals, keeping only files.
|
37 | url.startsWith("file://") &&
|
38 | // Exclude `node_modules` directory files.
|
39 | !url.includes("/node_modules/") &&
|
40 | // Exclude `test` directory files.
|
41 | !url.includes("/test/") &&
|
42 | // Exclude files with `.test` prefixed before the extension.
|
43 | !/\.test\.\w+$/u.test(url) &&
|
44 | // Exclude files named `test` (regardless of extension).
|
45 | !/\/test\.\w+$/u.test(url)
|
46 | ),
|
47 | };
|
48 | })
|
49 | );
|
50 |
|
51 | const mergedCoverage = v8Coverage.mergeProcessCovs(
|
52 | await Promise.all(filteredProcessCoverages)
|
53 | );
|
54 |
|
55 | /** @type {CoverageAnalysis} */
|
56 | const analysis = {
|
57 | filesCount: 0,
|
58 | covered: [],
|
59 | ignored: [],
|
60 | uncovered: [],
|
61 | };
|
62 |
|
63 | for (const { url, functions } of mergedCoverage.result) {
|
64 | analysis.filesCount++;
|
65 |
|
66 | const path = fileURLToPath(url);
|
67 | const uncoveredRanges = [];
|
68 |
|
69 | for (const { ranges } of functions)
|
70 | for (const range of ranges) if (!range.count) uncoveredRanges.push(range);
|
71 |
|
72 | if (uncoveredRanges.length) {
|
73 | const source = await fs.promises.readFile(path, "utf8");
|
74 | const ignored = [];
|
75 | const uncovered = [];
|
76 |
|
77 | for (const range of uncoveredRanges) {
|
78 | const sourceCodeRange = sourceRange(
|
79 | source,
|
80 | range.startOffset,
|
81 | // The coverage data end offset is the first character after the
|
82 | // range. For reporting to a user, it’s better to show the range as
|
83 | // only the included characters.
|
84 | range.endOffset - 1
|
85 | );
|
86 |
|
87 | if (sourceCodeRange.ignore) ignored.push(sourceCodeRange);
|
88 | else uncovered.push(sourceCodeRange);
|
89 | }
|
90 |
|
91 | if (ignored.length) analysis.ignored.push({ path, ranges: ignored });
|
92 | if (uncovered.length)
|
93 | analysis.uncovered.push({ path, ranges: uncovered });
|
94 | } else analysis.covered.push(path);
|
95 | }
|
96 |
|
97 | return analysis;
|
98 | }
|
99 |
|
100 | /**
|
101 | * [Node.js generated V8 JavaScript code coverage data](https://nodejs.org/api/cli.html#cli_node_v8_coverage_dir)
|
102 | * analysis; useful for reporting.
|
103 | * @typedef {object} CoverageAnalysis
|
104 | * @prop {number} filesCount Number of files analyzed.
|
105 | * @prop {Array<string>} covered Covered file absolute paths.
|
106 | * @prop {Array<SourceCodeRanges>} ignored Ignored source code ranges.
|
107 | * @prop {Array<SourceCodeRanges>} uncovered Uncovered source code ranges.
|
108 | */
|
109 |
|
110 | /**
|
111 | * A source code file with ranges of interest.
|
112 | * @typedef {object} SourceCodeRanges
|
113 | * @prop {string} path File absolute path.
|
114 | * @prop {Array<import("./sourceRange.mjs").SourceCodeRange>} ranges Ranges of
|
115 | * interest.
|
116 | */
|