1'use strict';
2const {
3  ArrayPrototypeJoin,
4  ArrayPrototypeMap,
5  ArrayPrototypePush,
6  ArrayPrototypeReduce,
7  ObjectCreate,
8  ObjectGetOwnPropertyDescriptor,
9  MathFloor,
10  MathMax,
11  MathMin,
12  NumberPrototypeToFixed,
13  SafePromiseAllReturnArrayLike,
14  RegExp,
15  RegExpPrototypeExec,
16  SafeMap,
17  StringPrototypePadStart,
18  StringPrototypePadEnd,
19  StringPrototypeRepeat,
20  StringPrototypeSlice,
21} = primordials;
22
23const { AsyncResource } = require('async_hooks');
24const { basename, relative } = require('path');
25const { createWriteStream } = require('fs');
26const { pathToFileURL } = require('internal/url');
27const { createDeferredPromise } = require('internal/util');
28const { getOptionValue } = require('internal/options');
29const { green, yellow, red, white, shouldColorize } = require('internal/util/colors');
30
31const {
32  codes: {
33    ERR_INVALID_ARG_VALUE,
34    ERR_TEST_FAILURE,
35  },
36  kIsNodeError,
37} = require('internal/errors');
38const { compose } = require('stream');
39
40const coverageColors = {
41  __proto__: null,
42  high: green,
43  medium: yellow,
44  low: red,
45};
46
47const kMultipleCallbackInvocations = 'multipleCallbackInvocations';
48const kRegExpPattern = /^\/(.*)\/([a-z]*)$/;
49const kSupportedFileExtensions = /\.[cm]?js$/;
50const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/;
51
52function doesPathMatchFilter(p) {
53  return RegExpPrototypeExec(kTestFilePattern, basename(p)) !== null;
54}
55
56function isSupportedFileType(p) {
57  return RegExpPrototypeExec(kSupportedFileExtensions, p) !== null;
58}
59
60function createDeferredCallback() {
61  let calledCount = 0;
62  const { promise, resolve, reject } = createDeferredPromise();
63  const cb = (err) => {
64    calledCount++;
65
66    // If the callback is called a second time, let the user know, but
67    // don't let them know more than once.
68    if (calledCount > 1) {
69      if (calledCount === 2) {
70        throw new ERR_TEST_FAILURE(
71          'callback invoked multiple times',
72          kMultipleCallbackInvocations,
73        );
74      }
75
76      return;
77    }
78
79    if (err) {
80      return reject(err);
81    }
82
83    resolve();
84  };
85
86  return { __proto__: null, promise, cb };
87}
88
89function isTestFailureError(err) {
90  return err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err;
91}
92
93function convertStringToRegExp(str, name) {
94  const match = RegExpPrototypeExec(kRegExpPattern, str);
95  const pattern = match?.[1] ?? str;
96  const flags = match?.[2] || '';
97
98  try {
99    return new RegExp(pattern, flags);
100  } catch (err) {
101    const msg = err?.message;
102
103    throw new ERR_INVALID_ARG_VALUE(
104      name,
105      str,
106      `is an invalid regular expression.${msg ? ` ${msg}` : ''}`,
107    );
108  }
109}
110
111const kBuiltinDestinations = new SafeMap([
112  ['stdout', process.stdout],
113  ['stderr', process.stderr],
114]);
115
116const kBuiltinReporters = new SafeMap([
117  ['spec', 'internal/test_runner/reporter/spec'],
118  ['dot', 'internal/test_runner/reporter/dot'],
119  ['tap', 'internal/test_runner/reporter/tap'],
120  ['junit', 'internal/test_runner/reporter/junit'],
121]);
122
123const kDefaultReporter = process.stdout.isTTY ? 'spec' : 'tap';
124const kDefaultDestination = 'stdout';
125
126function tryBuiltinReporter(name) {
127  const builtinPath = kBuiltinReporters.get(name);
128
129  if (builtinPath === undefined) {
130    return;
131  }
132
133  return require(builtinPath);
134}
135
136async function getReportersMap(reporters, destinations, rootTest) {
137  return SafePromiseAllReturnArrayLike(reporters, async (name, i) => {
138    const destination = kBuiltinDestinations.get(destinations[i]) ?? createWriteStream(destinations[i]);
139    rootTest.harness.shouldColorizeTestFiles ||= shouldColorize(destination);
140
141    // Load the test reporter passed to --test-reporter
142    let reporter = tryBuiltinReporter(name);
143
144    if (reporter === undefined) {
145      let parentURL;
146
147      try {
148        parentURL = pathToFileURL(process.cwd() + '/').href;
149      } catch {
150        parentURL = 'file:///';
151      }
152
153      const { esmLoader } = require('internal/process/esm_loader');
154      reporter = await esmLoader.import(name, parentURL, ObjectCreate(null));
155    }
156
157    if (reporter?.default) {
158      reporter = reporter.default;
159    }
160
161    if (reporter?.prototype && ObjectGetOwnPropertyDescriptor(reporter.prototype, 'constructor')) {
162      reporter = new reporter();
163    }
164
165    if (!reporter) {
166      throw new ERR_INVALID_ARG_VALUE('Reporter', name, 'is not a valid reporter');
167    }
168
169    return { __proto__: null, reporter, destination };
170  });
171}
172
173const reporterScope = new AsyncResource('TestReporterScope');
174const setupTestReporters = reporterScope.bind(async (rootTest) => {
175  const { reporters, destinations } = parseCommandLine();
176  const reportersMap = await getReportersMap(reporters, destinations, rootTest);
177  for (let i = 0; i < reportersMap.length; i++) {
178    const { reporter, destination } = reportersMap[i];
179    compose(rootTest.reporter, reporter).pipe(destination);
180  }
181});
182
183let globalTestOptions;
184
185function parseCommandLine() {
186  if (globalTestOptions) {
187    return globalTestOptions;
188  }
189
190  const isTestRunner = getOptionValue('--test');
191  const coverage = getOptionValue('--experimental-test-coverage');
192  const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
193  const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
194  let destinations;
195  let reporters;
196  let testNamePatterns;
197  let testOnlyFlag;
198
199  if (isChildProcessV8) {
200    kBuiltinReporters.set('v8-serializer', 'internal/test_runner/reporter/v8-serializer');
201    reporters = ['v8-serializer'];
202    destinations = [kDefaultDestination];
203  } else if (isChildProcess) {
204    reporters = ['tap'];
205    destinations = [kDefaultDestination];
206  } else {
207    destinations = getOptionValue('--test-reporter-destination');
208    reporters = getOptionValue('--test-reporter');
209    if (reporters.length === 0 && destinations.length === 0) {
210      ArrayPrototypePush(reporters, kDefaultReporter);
211    }
212
213    if (reporters.length === 1 && destinations.length === 0) {
214      ArrayPrototypePush(destinations, kDefaultDestination);
215    }
216
217    if (destinations.length !== reporters.length) {
218      throw new ERR_INVALID_ARG_VALUE(
219        '--test-reporter',
220        reporters,
221        'must match the number of specified \'--test-reporter-destination\'',
222      );
223    }
224  }
225
226  if (isTestRunner) {
227    testOnlyFlag = false;
228    testNamePatterns = null;
229  } else {
230    const testNamePatternFlag = getOptionValue('--test-name-pattern');
231    testOnlyFlag = getOptionValue('--test-only');
232    testNamePatterns = testNamePatternFlag?.length > 0 ?
233      ArrayPrototypeMap(
234        testNamePatternFlag,
235        (re) => convertStringToRegExp(re, '--test-name-pattern'),
236      ) : null;
237  }
238
239  globalTestOptions = {
240    __proto__: null,
241    isTestRunner,
242    coverage,
243    testOnlyFlag,
244    testNamePatterns,
245    reporters,
246    destinations,
247  };
248
249  return globalTestOptions;
250}
251
252function countCompletedTest(test, harness = test.root.harness) {
253  if (test.nesting === 0) {
254    harness.counters.topLevel++;
255  }
256  if (test.reportedType === 'suite') {
257    harness.counters.suites++;
258    return;
259  }
260  // Check SKIP and TODO tests first, as those should not be counted as
261  // failures.
262  if (test.skipped) {
263    harness.counters.skipped++;
264  } else if (test.isTodo) {
265    harness.counters.todo++;
266  } else if (test.cancelled) {
267    harness.counters.cancelled++;
268  } else if (!test.passed) {
269    harness.counters.failed++;
270  } else {
271    harness.counters.passed++;
272  }
273  harness.counters.all++;
274}
275
276
277const memo = new SafeMap();
278function addTableLine(prefix, width) {
279  const key = `${prefix}-${width}`;
280  let value = memo.get(key);
281  if (value === undefined) {
282    value = `${prefix}${StringPrototypeRepeat('-', width)}\n`;
283    memo.set(key, value);
284  }
285
286  return value;
287}
288
289const kHorizontalEllipsis = '\u2026';
290function truncateStart(string, width) {
291  return string.length > width ? `${kHorizontalEllipsis}${StringPrototypeSlice(string, string.length - width + 1)}` : string;
292}
293
294function truncateEnd(string, width) {
295  return string.length > width ? `${StringPrototypeSlice(string, 0, width - 1)}${kHorizontalEllipsis}` : string;
296}
297
298function formatLinesToRanges(values) {
299  return ArrayPrototypeMap(ArrayPrototypeReduce(values, (prev, current, index, array) => {
300    if ((index > 0) && ((current - array[index - 1]) === 1)) {
301      prev[prev.length - 1][1] = current;
302    } else {
303      prev.push([current]);
304    }
305    return prev;
306  }, []), (range) => ArrayPrototypeJoin(range, '-'));
307}
308
309function formatUncoveredLines(lines, table) {
310  if (table) return ArrayPrototypeJoin(formatLinesToRanges(lines), ' ');
311  return ArrayPrototypeJoin(lines, ', ');
312}
313
314const kColumns = ['line %', 'branch %', 'funcs %'];
315const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
316const kSeparator = ' | ';
317
318function getCoverageReport(pad, summary, symbol, color, table) {
319  const prefix = `${pad}${symbol}`;
320  let report = `${color}${prefix}start of coverage report\n`;
321
322  let filePadLength;
323  let columnPadLengths = [];
324  let uncoveredLinesPadLength;
325  let tableWidth;
326
327  if (table) {
328    // Get expected column sizes
329    filePadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) =>
330      MathMax(acc, relative(summary.workingDirectory, file.path).length), 0);
331    filePadLength = MathMax(filePadLength, 'file'.length);
332    const fileWidth = filePadLength + 2;
333
334    columnPadLengths = ArrayPrototypeMap(kColumns, (column) => (table ? MathMax(column.length, 6) : 0));
335    const columnsWidth = ArrayPrototypeReduce(columnPadLengths, (acc, columnPadLength) => acc + columnPadLength + 3, 0);
336
337    uncoveredLinesPadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) =>
338      MathMax(acc, formatUncoveredLines(file.uncoveredLineNumbers, table).length), 0);
339    uncoveredLinesPadLength = MathMax(uncoveredLinesPadLength, 'uncovered lines'.length);
340    const uncoveredLinesWidth = uncoveredLinesPadLength + 2;
341
342    tableWidth = fileWidth + columnsWidth + uncoveredLinesWidth;
343
344    // Fit with sensible defaults
345    const availableWidth = (process.stdout.columns || Infinity) - prefix.length;
346    const columnsExtras = tableWidth - availableWidth;
347    if (table && columnsExtras > 0) {
348      // Ensure file name is sufficiently visible
349      const minFilePad = MathMin(8, filePadLength);
350      filePadLength -= MathFloor(columnsExtras * 0.2);
351      filePadLength = MathMax(filePadLength, minFilePad);
352
353      // Get rest of available space, subtracting margins
354      uncoveredLinesPadLength = MathMax(availableWidth - columnsWidth - (filePadLength + 2) - 2, 1);
355
356      // Update table width
357      tableWidth = availableWidth;
358    } else {
359      uncoveredLinesPadLength = Infinity;
360    }
361  }
362
363
364  function getCell(string, width, pad, truncate, coverage) {
365    if (!table) return string;
366
367    let result = string;
368    if (pad) result = pad(result, width);
369    if (truncate) result = truncate(result, width);
370    if (color && coverage !== undefined) {
371      if (coverage > 90) return `${coverageColors.high}${result}${color}`;
372      if (coverage > 50) return `${coverageColors.medium}${result}${color}`;
373      return `${coverageColors.low}${result}${color}`;
374    }
375    return result;
376  }
377
378  // Head
379  if (table) report += addTableLine(prefix, tableWidth);
380  report += `${prefix}${getCell('file', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` +
381            `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumns, (column, i) => getCell(column, columnPadLengths[i], StringPrototypePadStart)), kSeparator)}${kSeparator}` +
382            `${getCell('uncovered lines', uncoveredLinesPadLength, false, truncateEnd)}\n`;
383  if (table) report += addTableLine(prefix, tableWidth);
384
385  // Body
386  for (let i = 0; i < summary.files.length; ++i) {
387    const file = summary.files[i];
388    const relativePath = relative(summary.workingDirectory, file.path);
389
390    let fileCoverage = 0;
391    const coverages = ArrayPrototypeMap(kColumnsKeys, (columnKey) => {
392      const percent = file[columnKey];
393      fileCoverage += percent;
394      return percent;
395    });
396    fileCoverage /= kColumnsKeys.length;
397
398    report += `${prefix}${getCell(relativePath, filePadLength, StringPrototypePadEnd, truncateStart, fileCoverage)}${kSeparator}` +
399              `${ArrayPrototypeJoin(ArrayPrototypeMap(coverages, (coverage, j) => getCell(NumberPrototypeToFixed(coverage, 2), columnPadLengths[j], StringPrototypePadStart, false, coverage)), kSeparator)}${kSeparator}` +
400              `${getCell(formatUncoveredLines(file.uncoveredLineNumbers, table), uncoveredLinesPadLength, false, truncateEnd)}\n`;
401  }
402
403  // Foot
404  if (table) report += addTableLine(prefix, tableWidth);
405  report += `${prefix}${getCell('all files', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` +
406            `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`;
407  if (table) report += addTableLine(prefix, tableWidth);
408
409  report += `${prefix}end of coverage report\n`;
410  if (color) {
411    report += white;
412  }
413  return report;
414}
415
416module.exports = {
417  convertStringToRegExp,
418  countCompletedTest,
419  createDeferredCallback,
420  doesPathMatchFilter,
421  isSupportedFileType,
422  isTestFailureError,
423  parseCommandLine,
424  reporterScope,
425  setupTestReporters,
426  getCoverageReport,
427};
428