UNPKG

8.07 kBJavaScriptView Raw
1const Exclude = require('test-exclude')
2const furi = require('furi')
3const libCoverage = require('istanbul-lib-coverage')
4const libReport = require('istanbul-lib-report')
5const reports = require('istanbul-reports')
6const { readdirSync, readFileSync, statSync } = require('fs')
7const { isAbsolute, resolve, extname } = require('path')
8const getSourceMapFromFile = require('./source-map-from-file')
9// TODO: switch back to @c88/v8-coverage once patch is landed.
10const v8toIstanbul = require('v8-to-istanbul')
11const isCjsEsmBridgeCov = require('./is-cjs-esm-bridge')
12
13class Report {
14 constructor ({
15 exclude,
16 include,
17 reporter,
18 reportsDirectory,
19 tempDirectory,
20 watermarks,
21 omitRelative,
22 wrapperLength,
23 resolve: resolvePaths,
24 all
25 }) {
26 this.reporter = reporter
27 this.reportsDirectory = reportsDirectory
28 this.tempDirectory = tempDirectory
29 this.watermarks = watermarks
30 this.resolve = resolvePaths
31 this.exclude = new Exclude({
32 exclude: exclude,
33 include: include
34 })
35 this.omitRelative = omitRelative
36 this.sourceMapCache = {}
37 this.wrapperLength = wrapperLength
38 this.all = all
39 this.src = process.cwd()
40 }
41
42 async run () {
43 var context = libReport.createContext({
44 dir: this.reportsDirectory,
45 watermarks: this.watermarks,
46 coverageMap: await this.getCoverageMapFromAllCoverageFiles()
47 })
48
49 this.reporter.forEach(function (_reporter) {
50 reports.create(_reporter, {
51 skipEmpty: false,
52 skipFull: false,
53 maxCols: 100
54 }).execute(context)
55 })
56 }
57
58 async getCoverageMapFromAllCoverageFiles () {
59 // the merge process can be very expensive, and it's often the case that
60 // check-coverage is called immediately after a report. We memoize the
61 // result from getCoverageMapFromAllCoverageFiles() to address this
62 // use-case.
63 if (this._allCoverageFiles) return this._allCoverageFiles
64
65 const map = libCoverage.createCoverageMap()
66 const v8ProcessCov = this._getMergedProcessCov()
67 const resultCountPerPath = new Map()
68 const possibleCjsEsmBridges = new Map()
69
70 for (const v8ScriptCov of v8ProcessCov.result) {
71 try {
72 const sources = this._getSourceMap(v8ScriptCov)
73 const path = resolve(this.resolve, v8ScriptCov.url)
74 const converter = v8toIstanbul(path, this.wrapperLength, sources)
75 await converter.load()
76
77 if (resultCountPerPath.has(path)) {
78 resultCountPerPath.set(path, resultCountPerPath.get(path) + 1)
79 } else {
80 resultCountPerPath.set(path, 0)
81 }
82
83 if (isCjsEsmBridgeCov(v8ScriptCov)) {
84 possibleCjsEsmBridges.set(converter, {
85 path,
86 functions: v8ScriptCov.functions
87 })
88 } else {
89 converter.applyCoverage(v8ScriptCov.functions)
90 map.merge(converter.toIstanbul())
91 }
92 } catch (err) {
93 console.warn(`file: ${v8ScriptCov.url} error: ${err.stack}`)
94 }
95 }
96
97 for (const [converter, { path, functions }] of possibleCjsEsmBridges) {
98 if (resultCountPerPath.get(path) <= 1) {
99 converter.applyCoverage(functions)
100 map.merge(converter.toIstanbul())
101 }
102 }
103 this._allCoverageFiles = map
104 return this._allCoverageFiles
105 }
106
107 /**
108 * Returns source-map and fake source file, if cached during Node.js'
109 * execution. This is used to support tools like ts-node, which transpile
110 * using runtime hooks.
111 *
112 * Note: requires Node.js 13+
113 *
114 * @return {Object} sourceMap and fake source file (created from line #s).
115 * @private
116 */
117 _getSourceMap (v8ScriptCov) {
118 const sources = {}
119 if (this.sourceMapCache[`file://${v8ScriptCov.url}`]) {
120 const sourceMapAndLineLengths = this.sourceMapCache[`file://${v8ScriptCov.url}`]
121 sources.sourceMap = {
122 sourcemap: sourceMapAndLineLengths.data
123 }
124 if (sourceMapAndLineLengths.lineLengths) {
125 let source = ''
126 sourceMapAndLineLengths.lineLengths.forEach(length => {
127 source += `${''.padEnd(length, '.')}\n`
128 })
129 sources.source = source
130 }
131 }
132 return sources
133 }
134
135 /**
136 * Returns the merged V8 process coverage.
137 *
138 * The result is computed from the individual process coverages generated
139 * by Node. It represents the sum of their counts.
140 *
141 * @return {ProcessCov} Merged V8 process coverage.
142 * @private
143 */
144 _getMergedProcessCov () {
145 const { mergeProcessCovs } = require('@bcoe/v8-coverage')
146 const v8ProcessCovs = []
147 const fileIndex = new Set() // Set<string>
148 for (const v8ProcessCov of this._loadReports()) {
149 if (this._isCoverageObject(v8ProcessCov)) {
150 if (v8ProcessCov['source-map-cache']) {
151 Object.assign(this.sourceMapCache, v8ProcessCov['source-map-cache'])
152 }
153 v8ProcessCovs.push(this._normalizeProcessCov(v8ProcessCov, fileIndex))
154 }
155 }
156
157 if (this.all) {
158 const emptyReports = []
159 v8ProcessCovs.unshift({
160 result: emptyReports
161 })
162 const workingDir = process.cwd()
163 this.exclude.globSync(workingDir).forEach((f) => {
164 const fullPath = resolve(workingDir, f)
165 if (!fileIndex.has(fullPath)) {
166 const ext = extname(f)
167 if (ext === '.js' || ext === '.ts' || ext === '.mjs') {
168 const stat = statSync(f)
169 const sourceMap = getSourceMapFromFile(f)
170 if (sourceMap !== undefined) {
171 this.sourceMapCache[`file://${fullPath}`] = { data: JSON.parse(readFileSync(sourceMap).toString()) }
172 }
173 emptyReports.push({
174 scriptId: 0,
175 url: resolve(f),
176 functions: [{
177 functionName: '(empty-report)',
178 ranges: [{
179 startOffset: 0,
180 endOffset: stat.size,
181 count: 0
182 }],
183 isBlockCoverage: true
184 }]
185 })
186 }
187 }
188 })
189 }
190
191 return mergeProcessCovs(v8ProcessCovs)
192 }
193
194 /**
195 * Make sure v8ProcessCov actually contains coverage information.
196 *
197 * @return {boolean} does it look like v8ProcessCov?
198 * @private
199 */
200 _isCoverageObject (maybeV8ProcessCov) {
201 return maybeV8ProcessCov && Array.isArray(maybeV8ProcessCov.result)
202 }
203
204 /**
205 * Returns the list of V8 process coverages generated by Node.
206 *
207 * @return {ProcessCov[]} Process coverages generated by Node.
208 * @private
209 */
210 _loadReports () {
211 const files = readdirSync(this.tempDirectory)
212
213 return files.map((f) => {
214 try {
215 return JSON.parse(readFileSync(
216 resolve(this.tempDirectory, f),
217 'utf8'
218 ))
219 } catch (err) {
220 console.warn(`${err.stack}`)
221 }
222 })
223 }
224
225 /**
226 * Normalizes a process coverage.
227 *
228 * This function replaces file URLs (`url` property) by their corresponding
229 * system-dependent path and applies the current inclusion rules to filter out
230 * the excluded script coverages.
231 *
232 * The result is a copy of the input, with script coverages filtered based
233 * on their `url` and the current inclusion rules.
234 * There is no deep cloning.
235 *
236 * @param v8ProcessCov V8 process coverage to normalize.
237 * @param fileIndex a Set<string> of paths discovered in coverage
238 * @return {v8ProcessCov} Normalized V8 process coverage.
239 * @private
240 */
241 _normalizeProcessCov (v8ProcessCov, fileIndex) {
242 const result = []
243 for (const v8ScriptCov of v8ProcessCov.result) {
244 if (/^file:\/\//.test(v8ScriptCov.url)) {
245 try {
246 v8ScriptCov.url = furi.toSysPath(v8ScriptCov.url)
247 fileIndex.add(v8ScriptCov.url)
248 } catch (err) {
249 console.warn(err)
250 continue
251 }
252 }
253 if (this.exclude.shouldInstrument(v8ScriptCov.url) &&
254 (!this.omitRelative || isAbsolute(v8ScriptCov.url))) {
255 result.push(v8ScriptCov)
256 }
257 }
258 return { result }
259 }
260}
261
262module.exports = function (opts) {
263 return new Report(opts)
264}