UNPKG

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