superc8 1.0.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.
package/lib/report.js ADDED
@@ -0,0 +1,557 @@
1
+ import {
2
+ readdirSync,
3
+ readFileSync,
4
+ statSync,
5
+ } from 'node:fs';
6
+ import {
7
+ isAbsolute,
8
+ resolve,
9
+ extname,
10
+ } from 'node:path';
11
+ import {pathToFileURL, fileURLToPath} from 'node:url';
12
+ import util from 'node:util';
13
+ import {readFile} from 'node:fs/promises';
14
+ import process from 'node:process';
15
+ import Exclude from 'test-exclude';
16
+ import libCoverage from 'istanbul-lib-coverage';
17
+ import libReport from 'istanbul-lib-report';
18
+ import reports from 'istanbul-reports';
19
+ import v8toIstanbul from 'v8-to-istanbul';
20
+ import {mergeProcessCovs} from '@bcoe/v8-coverage';
21
+ // TODO: switch back to @c88/v8-coverage once patch is landed.
22
+ import getSourceMapFromFile from './source-map-from-file.js';
23
+
24
+ const {isArray} = Array;
25
+ const isUndefined = (a) => typeof a === 'undefined';
26
+ const maybeArray = (a) => isArray(a) ? a : [a];
27
+ const isString = (a) => typeof a === 'string';
28
+ const consoleError = console.error;
29
+
30
+ const debuglog = util.debuglog('c8');
31
+
32
+ class ReportInstance {
33
+ constructor({exclude, extension, excludeAfterRemap, include, reporter, reporterOptions, reportsDirectory, tempDirectory, watermarks, omitRelative, wrapperLength, resolve: resolvePaths, all, src, allowExternal = false, skipFull, excludeNodeModules, mergeAsync, monocartArgv}) {
34
+ this.reporter = reporter;
35
+ this.reporterOptions = reporterOptions || {};
36
+ this.reportsDirectory = reportsDirectory;
37
+ this.tempDirectory = tempDirectory;
38
+ this.watermarks = watermarks;
39
+ this.resolve = resolvePaths;
40
+ this.exclude = new Exclude({
41
+ exclude,
42
+ include,
43
+ extension,
44
+ relativePath: !allowExternal,
45
+ excludeNodeModules,
46
+ });
47
+ this.excludeAfterRemap = excludeAfterRemap;
48
+ this.shouldInstrumentCache = new Map();
49
+ this.omitRelative = omitRelative;
50
+ this.sourceMapCache = {};
51
+ this.wrapperLength = wrapperLength;
52
+ this.all = all;
53
+ this.src = this._getSrc(src);
54
+ this.skipFull = skipFull;
55
+ this.mergeAsync = mergeAsync;
56
+ this.monocartArgv = monocartArgv;
57
+ }
58
+
59
+ _getSrc(src) {
60
+ if (isString(src))
61
+ return [src];
62
+
63
+ if (Array.isArray(src))
64
+ return src;
65
+
66
+ return [
67
+ process.cwd(),
68
+ ];
69
+ }
70
+
71
+ async run() {
72
+ if (this.monocartArgv)
73
+ return this.runMonocart();
74
+
75
+ const context = libReport.createContext({
76
+ dir: this.reportsDirectory,
77
+ watermarks: this.watermarks,
78
+ coverageMap: await this.getCoverageMapFromAllCoverageFiles(),
79
+ });
80
+
81
+ for (const _reporter of this.reporter) {
82
+ reports
83
+ .create(_reporter, {
84
+ skipEmpty: false,
85
+ skipFull: this.skipFull,
86
+ maxCols: process.stdout.columns || 100,
87
+ ...this.reporterOptions[_reporter],
88
+ })
89
+ .execute(context);
90
+ }
91
+ }
92
+
93
+ async importMonocart() {
94
+ return import('monocart-coverage-reports');
95
+ }
96
+
97
+ async getMonocart() {
98
+ let MCR;
99
+
100
+ try {
101
+ MCR = await this.importMonocart();
102
+ } catch {
103
+ consoleError('--experimental-monocart requires the plugin monocart-coverage-reports. Run: "npm i monocart-coverage-reports@2 --save-dev"');
104
+ process.exit(1);
105
+ }
106
+
107
+ return MCR;
108
+ }
109
+
110
+ async runMonocart() {
111
+ const MCR = await this.getMonocart();
112
+
113
+ if (!MCR)
114
+ return;
115
+
116
+ const argv = this.monocartArgv;
117
+ const {exclude} = this;
118
+
119
+ function getEntryFilter() {
120
+ return argv.entryFilter || argv.filter || function(entry) {
121
+ return exclude.shouldInstrument(fileURLToPath(entry.url));
122
+ };
123
+ }
124
+
125
+ function getSourceFilter() {
126
+ return argv.sourceFilter || argv.filter || function(sourcePath) {
127
+ if (argv.excludeAfterRemap)
128
+ return exclude.shouldInstrument(sourcePath);
129
+
130
+ return true;
131
+ };
132
+ }
133
+
134
+ function getReports() {
135
+ const reports = maybeArray(argv.reporter);
136
+ const reporterOptions = argv.reporterOptions || {};
137
+
138
+ return reports.map((reportName) => {
139
+ const reportOptions = {
140
+ ...reporterOptions[reportName],
141
+ };
142
+
143
+ if (reportName === 'text') {
144
+ reportOptions.skipEmpty = false;
145
+ reportOptions.skipFull = argv.skipFull;
146
+ reportOptions.maxCols = process.stdout.columns || 100;
147
+ }
148
+
149
+ return [reportName, reportOptions];
150
+ });
151
+ }
152
+
153
+ // --all: add empty coverage for all files
154
+ function getAllOptions() {
155
+ if (!argv.all)
156
+ return;
157
+
158
+ const {src} = argv;
159
+ const workingDirs = Array.isArray(src) ? src : isString(src) ? [src] : [
160
+ process.cwd(),
161
+ ];
162
+
163
+ return {
164
+ dir: workingDirs,
165
+ filter: (filePath) => {
166
+ return exclude.shouldInstrument(filePath);
167
+ },
168
+ };
169
+ }
170
+
171
+ function initPct(summary) {
172
+ for (const k of Object.keys(summary)) {
173
+ if (summary[k].pct === '')
174
+ summary[k].pct = 100;
175
+ }
176
+
177
+ return summary;
178
+ }
179
+
180
+ // adapt coverage options
181
+ const coverageOptions = {
182
+ logging: argv.logging,
183
+ name: argv.name,
184
+
185
+ reports: getReports(),
186
+
187
+ outputDir: argv.reportsDir,
188
+ baseDir: argv.baseDir,
189
+
190
+ entryFilter: getEntryFilter(),
191
+ sourceFilter: getSourceFilter(),
192
+
193
+ inline: argv.inline,
194
+ lcov: argv.lcov,
195
+
196
+ all: getAllOptions(),
197
+
198
+ clean: argv.clean,
199
+ // use default value for istanbul
200
+ defaultSummarizer: 'pkg',
201
+
202
+ onEnd: (coverageResults) => {
203
+ // for check coverage
204
+ this._allCoverageFiles = {
205
+ files: () => {
206
+ return coverageResults.files.map((it) => it.sourcePath);
207
+ },
208
+ fileCoverageFor: (file) => {
209
+ const fileCoverage = coverageResults.files.find((it) => it.sourcePath === file);
210
+
211
+ return {
212
+ toSummary: () => {
213
+ return initPct(fileCoverage.summary);
214
+ },
215
+ };
216
+ },
217
+ getCoverageSummary: () => {
218
+ return initPct(coverageResults.summary);
219
+ },
220
+ };
221
+ },
222
+ };
223
+
224
+ const coverageReport = new MCR.CoverageReport(coverageOptions);
225
+
226
+ coverageReport.cleanCache();
227
+ // read v8 coverage data from tempDirectory
228
+ await coverageReport.addFromDir(argv.tempDirectory);
229
+ // generate report
230
+ await coverageReport.generate();
231
+ }
232
+
233
+ async getCoverageMapFromAllCoverageFiles() {
234
+ // the merge process can be very expensive, and it's often the case that
235
+ // check-coverage is called immediately after a report. We memoize the
236
+ // result from getCoverageMapFromAllCoverageFiles() to address this
237
+ // use-case.
238
+ if (this._allCoverageFiles)
239
+ return this._allCoverageFiles;
240
+
241
+ const map = libCoverage.createCoverageMap();
242
+ let v8ProcessCov;
243
+
244
+ if (this.mergeAsync)
245
+ v8ProcessCov = await this._getMergedProcessCovAsync();
246
+ else
247
+ v8ProcessCov = this._getMergedProcessCov();
248
+
249
+ const resultCountPerPath = new Map();
250
+
251
+ for (const v8ScriptCov of v8ProcessCov.result) {
252
+ try {
253
+ const sources = this._getSourceMap(v8ScriptCov);
254
+ const path = resolve(this.resolve, v8ScriptCov.url);
255
+ const converter = v8toIstanbul(path, this.wrapperLength, sources, (path) => {
256
+ if (this.excludeAfterRemap)
257
+ return !this._shouldInstrument(path);
258
+ });
259
+
260
+ await converter.load();
261
+
262
+ if (resultCountPerPath.has(path))
263
+ resultCountPerPath.set(path, resultCountPerPath.get(path) + 1);
264
+ else
265
+ resultCountPerPath.set(path, 0);
266
+
267
+ converter.applyCoverage(v8ScriptCov.functions);
268
+ map.merge(converter.toIstanbul());
269
+ } catch(err) {
270
+ debuglog(`file: ${v8ScriptCov.url} error: ${err.stack}`);
271
+ }
272
+ }
273
+
274
+ this._allCoverageFiles = map;
275
+ return this._allCoverageFiles;
276
+ }
277
+ /**
278
+ * Returns source-map and fake source file, if cached during Node.js'
279
+ * execution. This is used to support tools like ts-node, which transpile
280
+ * using runtime hooks.
281
+ *
282
+ * Note: requires Node.js 13+
283
+ *
284
+ * @return {Object} sourceMap and fake source file (created from line #s).
285
+ * @private
286
+ */
287
+ _getSourceMap(v8ScriptCov) {
288
+ const sources = {};
289
+ const sourceMapAndLineLengths = this.sourceMapCache[pathToFileURL(v8ScriptCov.url).href];
290
+
291
+ if (sourceMapAndLineLengths) {
292
+ // See: https://github.com/nodejs/node/pull/34305
293
+ if (!sourceMapAndLineLengths.data)
294
+ return;
295
+
296
+ sources.sourceMap = {
297
+ sourcemap: sourceMapAndLineLengths.data,
298
+ };
299
+
300
+ if (sourceMapAndLineLengths.lineLengths) {
301
+ let source = '';
302
+
303
+ for (const length of sourceMapAndLineLengths.lineLengths) {
304
+ source += `${''.padEnd(length, '.')}\n`;
305
+ }
306
+
307
+ sources.source = source;
308
+ }
309
+ }
310
+
311
+ return sources;
312
+ }
313
+ /**
314
+ * Returns the merged V8 process coverage.
315
+ *
316
+ * The result is computed from the individual process coverages generated
317
+ * by Node. It represents the sum of their counts.
318
+ *
319
+ * @return {ProcessCov} Merged V8 process coverage.
320
+ * @private
321
+ */
322
+ _getMergedProcessCov() {
323
+ const v8ProcessCovs = [];
324
+ const fileIndex = new Set();
325
+
326
+ // Set<string>
327
+ for (const v8ProcessCov of this._loadReports()) {
328
+ if (this._isCoverageObject(v8ProcessCov)) {
329
+ if (v8ProcessCov['source-map-cache'])
330
+ Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(v8ProcessCov['source-map-cache']));
331
+
332
+ v8ProcessCovs.push(this._normalizeProcessCov(
333
+ v8ProcessCov,
334
+ fileIndex,
335
+ ));
336
+ }
337
+ }
338
+
339
+ if (this.all) {
340
+ const emptyReports = this._includeUncoveredFiles(fileIndex);
341
+
342
+ v8ProcessCovs.unshift({
343
+ result: emptyReports,
344
+ });
345
+ }
346
+
347
+ return mergeProcessCovs(v8ProcessCovs);
348
+ }
349
+ /**
350
+ * Returns the merged V8 process coverage.
351
+ *
352
+ * It asynchronously and incrementally reads and merges individual process coverages
353
+ * generated by Node. This can be used via the `--merge-async` CLI arg. It's intended
354
+ * to be used across a large multi-process test run.
355
+ *
356
+ * @return {ProcessCov} Merged V8 process coverage.
357
+ * @private
358
+ */
359
+ async _getMergedProcessCovAsync() {
360
+ const {mergeProcessCovs} = await import('@bcoe/v8-coverage');
361
+ const fileIndex = new Set(); // Set<string>
362
+ let mergedCov = null;
363
+
364
+ for (const file of readdirSync(this.tempDirectory)) {
365
+ try {
366
+ const rawFile = await readFile(resolve(this.tempDirectory, file), 'utf8');
367
+
368
+ let report = JSON.parse(rawFile);
369
+
370
+ if (this._isCoverageObject(report)) {
371
+ if (report['source-map-cache'])
372
+ Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(report['source-map-cache']));
373
+
374
+ report = this._normalizeProcessCov(report, fileIndex);
375
+
376
+ if (mergedCov)
377
+ mergedCov = mergeProcessCovs([mergedCov, report]);
378
+ else
379
+ mergedCov = mergeProcessCovs([report]);
380
+ }
381
+ } catch(err) {
382
+ debuglog(String(err.stack));
383
+ }
384
+ }
385
+
386
+ if (this.all) {
387
+ const emptyReports = this._includeUncoveredFiles(fileIndex);
388
+ const emptyReport = {
389
+ result: emptyReports,
390
+ };
391
+
392
+ mergedCov = mergeProcessCovs([emptyReport, mergedCov]);
393
+ }
394
+
395
+ return mergedCov;
396
+ }
397
+ /**
398
+ * Adds empty coverage reports to account for uncovered/untested code.
399
+ * This is only done when the `--all` flag is present.
400
+ *
401
+ * @param {Set} fileIndex list of files that have coverage
402
+ * @returns {Array} list of empty coverage reports
403
+ */
404
+ _includeUncoveredFiles(fileIndex) {
405
+ const emptyReports = [];
406
+ const workingDirs = this.src;
407
+ const {extension} = this.exclude;
408
+
409
+ for (const workingDir of workingDirs) {
410
+ for (const f of this.exclude.globSync(workingDir)) {
411
+ const fullPath = resolve(workingDir, f);
412
+
413
+ if (!fileIndex.has(fullPath)) {
414
+ const ext = extname(fullPath);
415
+
416
+ if (extension.includes(ext)) {
417
+ const stat = statSync(fullPath);
418
+ const sourceMap = getSourceMapFromFile(fullPath);
419
+
420
+ if (sourceMap)
421
+ this.sourceMapCache[pathToFileURL(fullPath)] = {
422
+ data: sourceMap,
423
+ };
424
+
425
+ emptyReports.push({
426
+ scriptId: 0,
427
+ url: resolve(fullPath),
428
+ functions: [{
429
+ functionName: '(empty-report)',
430
+ ranges: [{
431
+ startOffset: 0,
432
+ endOffset: stat.size,
433
+ count: 0,
434
+ }],
435
+ isBlockCoverage: true,
436
+ }],
437
+ });
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ return emptyReports;
444
+ }
445
+ /**
446
+ * Make sure v8ProcessCov actually contains coverage information.
447
+ *
448
+ * @return {boolean} does it look like v8ProcessCov?
449
+ * @private
450
+ */
451
+ _isCoverageObject(maybeV8ProcessCov) {
452
+ return maybeV8ProcessCov && Array.isArray(maybeV8ProcessCov.result);
453
+ }
454
+ /**
455
+ * Returns the list of V8 process coverages generated by Node.
456
+ *
457
+ * @return {ProcessCov[]} Process coverages generated by Node.
458
+ * @private
459
+ */
460
+ _loadReports() {
461
+ const reports = [];
462
+
463
+ for (const file of readdirSync(this.tempDirectory)) {
464
+ try {
465
+ reports.push(JSON.parse(readFileSync(resolve(this.tempDirectory, file), 'utf8')));
466
+ } catch(err) {
467
+ debuglog(String(err.stack));
468
+ }
469
+ }
470
+
471
+ return reports;
472
+ }
473
+ /**
474
+ * Normalizes a process coverage.
475
+ *
476
+ * This function replaces file URLs (`url` property) by their corresponding
477
+ * system-dependent path and applies the current inclusion rules to filter out
478
+ * the excluded script coverages.
479
+ *
480
+ * The result is a copy of the input, with script coverages filtered based
481
+ * on their `url` and the current inclusion rules.
482
+ * There is no deep cloning.
483
+ *
484
+ * @param v8ProcessCov V8 process coverage to normalize.
485
+ * @param fileIndex a Set<string> of paths discovered in coverage
486
+ * @return {v8ProcessCov} Normalized V8 process coverage.
487
+ * @private
488
+ */
489
+ _normalizeProcessCov(v8ProcessCov, fileIndex) {
490
+ const result = [];
491
+
492
+ for (const v8ScriptCov of v8ProcessCov.result) {
493
+ // https://github.com/nodejs/node/pull/35498 updates Node.js'
494
+ // builtin module filenames:
495
+ if (v8ScriptCov.url.startsWith('node:'))
496
+ v8ScriptCov.url = `${v8ScriptCov.url.replace(/^node:/, '')}.js`;
497
+
498
+ if (v8ScriptCov.url.startsWith('file://'))
499
+ try {
500
+ v8ScriptCov.url = fileURLToPath(v8ScriptCov.url);
501
+ fileIndex.add(v8ScriptCov.url);
502
+ } catch(err) {
503
+ debuglog(String(err.stack));
504
+ continue;
505
+ }
506
+
507
+ const shouldAddReport = !this.omitRelative
508
+ || isAbsolute(v8ScriptCov.url)
509
+ && this.excludeAfterRemap
510
+ || this._shouldInstrument(v8ScriptCov.url);
511
+
512
+ if (shouldAddReport)
513
+ result.push(v8ScriptCov);
514
+ }
515
+
516
+ return {
517
+ result,
518
+ };
519
+ }
520
+ /**
521
+ * Normalizes a V8 source map cache.
522
+ *
523
+ * This function normalizes file URLs to a system-independent format.
524
+ *
525
+ * @param v8SourceMapCache V8 source map cache to normalize.
526
+ * @return {v8SourceMapCache} Normalized V8 source map cache.
527
+ * @private
528
+ */
529
+ _normalizeSourceMapCache(v8SourceMapCache) {
530
+ const cache = {};
531
+
532
+ for (const fileURL of Object.keys(v8SourceMapCache)) {
533
+ cache[pathToFileURL(fileURLToPath(fileURL)).href] = v8SourceMapCache[fileURL];
534
+ }
535
+
536
+ return cache;
537
+ }
538
+ /**
539
+ * this.exclude.shouldInstrument with cache
540
+ *
541
+ * @private
542
+ * @return {boolean}
543
+ */
544
+ _shouldInstrument(filename) {
545
+ const cacheResult = this.shouldInstrumentCache.get(filename);
546
+
547
+ if (!isUndefined(cacheResult))
548
+ return cacheResult;
549
+
550
+ const result = this.exclude.shouldInstrument(filename);
551
+ this.shouldInstrumentCache.set(filename, result);
552
+
553
+ return result;
554
+ }
555
+ }
556
+
557
+ export const Report = (opts) => new ReportInstance(opts);
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Copyright Node.js contributors. All rights reserved.
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to
6
+ * deal in the Software without restriction, including without limitation the
7
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
8
+ * sell copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in
12
+ * all copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
20
+ * IN THE SOFTWARE.
21
+ */
22
+ // TODO(bcoe): this logic is ported from Node.js' internal source map
23
+ // helpers:
24
+ // https://github.com/nodejs/node/blob/master/lib/internal/source_map/source_map_cache.js
25
+ // we should to upstream and downstream fixes.
26
+ import {readFileSync} from 'node:fs';
27
+ import {fileURLToPath, pathToFileURL} from 'node:url';
28
+ import util from 'node:util';
29
+ import {Buffer} from 'node:buffer';
30
+
31
+ const debuglog = util.debuglog('c8');
32
+
33
+ /**
34
+ * Extract the sourcemap url from a source file
35
+ * reference: https://sourcemaps.info/spec.html
36
+ * @param {String} file - compilation target file
37
+ * @returns {String} full path to source map file
38
+ * @private
39
+ */
40
+ function getSourceMapFromFile(filename) {
41
+ const fileBody = readFileSync(filename).toString();
42
+ const sourceMapLineRE = /\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/;
43
+ const results = fileBody.match(sourceMapLineRE);
44
+
45
+ if (results !== null) {
46
+ const {sourceMappingURL} = results.groups;
47
+
48
+ return dataFromUrl(pathToFileURL(filename), sourceMappingURL);
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ function dataFromUrl(sourceURL, sourceMappingURL) {
55
+ try {
56
+ const url = new URL(sourceMappingURL);
57
+
58
+ switch(url.protocol) {
59
+ case 'data:':
60
+ return sourceMapFromDataUrl(url.pathname);
61
+
62
+ default:
63
+ return null;
64
+ }
65
+ } catch(err) {
66
+ debuglog(err);
67
+ // If no scheme is present, we assume we are dealing with a file path.
68
+ const mapURL = new URL(sourceMappingURL, sourceURL).href;
69
+
70
+ return sourceMapFromFile(mapURL);
71
+ }
72
+ }
73
+
74
+ function sourceMapFromFile(mapURL) {
75
+ try {
76
+ const content = readFileSync(fileURLToPath(mapURL), 'utf8');
77
+ return JSON.parse(content);
78
+ } catch(err) {
79
+ debuglog(err);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // data:[<mediatype>][;base64],<data> see:
85
+ // https://tools.ietf.org/html/rfc2397#section-2
86
+ function sourceMapFromDataUrl(url) {
87
+ const [format, data] = url.split(',');
88
+ const splitFormat = format.split(';');
89
+ const [contentType] = splitFormat;
90
+ const base64 = splitFormat.at(-1) === 'base64';
91
+
92
+ if (contentType === 'application/json') {
93
+ const decodedData = base64 ? Buffer
94
+ .from(data, 'base64')
95
+ .toString('utf8') : data;
96
+
97
+ try {
98
+ return JSON.parse(decodedData);
99
+ } catch(err) {
100
+ debuglog(err);
101
+ return null;
102
+ }
103
+ } else {
104
+ debuglog(`unexpected content-type ${contentType}`);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export default getSourceMapFromFile;