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