1'use strict';
2const {
3  ArrayFrom,
4  ArrayIsArray,
5  ArrayPrototypeFilter,
6  ArrayPrototypeForEach,
7  ArrayPrototypeIncludes,
8  ArrayPrototypeMap,
9  ArrayPrototypePush,
10  ArrayPrototypeShift,
11  ArrayPrototypeSlice,
12  ArrayPrototypeSome,
13  ArrayPrototypeSort,
14  ObjectAssign,
15  PromisePrototypeThen,
16  SafePromiseAll,
17  SafePromiseAllReturnVoid,
18  SafePromiseAllSettledReturnVoid,
19  PromiseResolve,
20  SafeMap,
21  SafeSet,
22  StringPrototypeIndexOf,
23  StringPrototypeSlice,
24  StringPrototypeStartsWith,
25  TypedArrayPrototypeGetLength,
26  TypedArrayPrototypeSubarray,
27} = primordials;
28
29const { spawn } = require('child_process');
30const { readdirSync, statSync } = require('fs');
31const { finished } = require('internal/streams/end-of-stream');
32const { DefaultDeserializer, DefaultSerializer } = require('v8');
33// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
34const { createInterface } = require('readline');
35const { deserializeError } = require('internal/error_serdes');
36const { Buffer } = require('buffer');
37const { FilesWatcher } = require('internal/watch_mode/files_watcher');
38const console = require('internal/console/global');
39const {
40  codes: {
41    ERR_INVALID_ARG_TYPE,
42    ERR_INVALID_ARG_VALUE,
43    ERR_TEST_FAILURE,
44    ERR_OUT_OF_RANGE,
45  },
46} = require('internal/errors');
47const {
48  validateArray,
49  validateBoolean,
50  validateFunction,
51  validateObject,
52  validateInteger,
53} = require('internal/validators');
54const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
55const { isRegExp } = require('internal/util/types');
56const { kEmptyObject } = require('internal/util');
57const { kEmitMessage } = require('internal/test_runner/tests_stream');
58const { createTestTree } = require('internal/test_runner/harness');
59const {
60  kAborted,
61  kCancelledByParent,
62  kSubtestsFailed,
63  kTestCodeFailure,
64  kTestTimeoutFailure,
65  Test,
66} = require('internal/test_runner/test');
67
68const {
69  convertStringToRegExp,
70  countCompletedTest,
71  doesPathMatchFilter,
72  isSupportedFileType,
73} = require('internal/test_runner/utils');
74const { basename, join, resolve } = require('path');
75const { once } = require('events');
76const {
77  triggerUncaughtException,
78} = internalBinding('errors');
79
80const kFilterArgs = ['--test', '--experimental-test-coverage', '--watch'];
81const kFilterArgValues = ['--test-reporter', '--test-reporter-destination'];
82const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms'];
83
84const kCanceledTests = new SafeSet()
85  .add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);
86
87let kResistStopPropagation;
88
89// TODO(cjihrig): Replace this with recursive readdir once it lands.
90function processPath(path, testFiles, options) {
91  const stats = statSync(path);
92
93  if (stats.isFile()) {
94    if (options.userSupplied ||
95        (options.underTestDir && isSupportedFileType(path)) ||
96        doesPathMatchFilter(path)) {
97      testFiles.add(path);
98    }
99  } else if (stats.isDirectory()) {
100    const name = basename(path);
101
102    if (!options.userSupplied && name === 'node_modules') {
103      return;
104    }
105
106    // 'test' directories get special treatment. Recursively add all .js,
107    // .cjs, and .mjs files in the 'test' directory.
108    const isTestDir = name === 'test';
109    const { underTestDir } = options;
110    const entries = readdirSync(path);
111
112    if (isTestDir) {
113      options.underTestDir = true;
114    }
115
116    options.userSupplied = false;
117
118    for (let i = 0; i < entries.length; i++) {
119      processPath(join(path, entries[i]), testFiles, options);
120    }
121
122    options.underTestDir = underTestDir;
123  }
124}
125
126function createTestFileList() {
127  const cwd = process.cwd();
128  const hasUserSuppliedPaths = process.argv.length > 1;
129  const testPaths = hasUserSuppliedPaths ?
130    ArrayPrototypeSlice(process.argv, 1) : [cwd];
131  const testFiles = new SafeSet();
132
133  try {
134    for (let i = 0; i < testPaths.length; i++) {
135      const absolutePath = resolve(testPaths[i]);
136
137      processPath(absolutePath, testFiles, {
138        __proto__: null,
139        userSupplied: true,
140      });
141    }
142  } catch (err) {
143    if (err?.code === 'ENOENT') {
144      console.error(`Could not find '${err.path}'`);
145      process.exit(1);
146    }
147
148    throw err;
149  }
150
151  return ArrayPrototypeSort(ArrayFrom(testFiles));
152}
153
154function filterExecArgv(arg, i, arr) {
155  return !ArrayPrototypeIncludes(kFilterArgs, arg) &&
156  !ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`));
157}
158
159function getRunArgs(path, { inspectPort, testNamePatterns, only }) {
160  const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
161  if (isUsingInspector()) {
162    ArrayPrototypePush(argv, `--inspect-port=${getInspectPort(inspectPort)}`);
163  }
164  if (testNamePatterns != null) {
165    ArrayPrototypeForEach(testNamePatterns, (pattern) => ArrayPrototypePush(argv, `--test-name-pattern=${pattern}`));
166  }
167  if (only === true) {
168    ArrayPrototypePush(argv, '--test-only');
169  }
170  ArrayPrototypePush(argv, path);
171
172  return argv;
173}
174
175const serializer = new DefaultSerializer();
176serializer.writeHeader();
177const v8Header = serializer.releaseBuffer();
178const kV8HeaderLength = TypedArrayPrototypeGetLength(v8Header);
179const kSerializedSizeHeader = 4 + kV8HeaderLength;
180
181class FileTest extends Test {
182  // This class maintains two buffers:
183  #reportBuffer = []; // Parsed items waiting for this.isClearToSend()
184  #rawBuffer = []; // Raw data waiting to be parsed
185  #rawBufferSize = 0;
186  #reportedChildren = 0;
187  failedSubtests = false;
188
189  constructor(options) {
190    super(options);
191    this.loc ??= {
192      __proto__: null,
193      line: 1,
194      column: 1,
195      file: resolve(this.name),
196    };
197  }
198
199  #skipReporting() {
200    return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed);
201  }
202  #checkNestedComment(comment) {
203    const firstSpaceIndex = StringPrototypeIndexOf(comment, ' ');
204    if (firstSpaceIndex === -1) return false;
205    const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1);
206    return secondSpaceIndex === -1 &&
207          ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex));
208  }
209  #handleReportItem(item) {
210    const isTopLevel = item.data.nesting === 0;
211    if (isTopLevel) {
212      if (item.type === 'test:plan' && this.#skipReporting()) {
213        return;
214      }
215      if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) {
216        return;
217      }
218    }
219    if (item.data.details?.error) {
220      item.data.details.error = deserializeError(item.data.details.error);
221    }
222    if (item.type === 'test:pass' || item.type === 'test:fail') {
223      item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
224      countCompletedTest({
225        __proto__: null,
226        name: item.data.name,
227        finished: true,
228        skipped: item.data.skip !== undefined,
229        isTodo: item.data.todo !== undefined,
230        passed: item.type === 'test:pass',
231        cancelled: kCanceledTests.has(item.data.details?.error?.failureType),
232        nesting: item.data.nesting,
233        reportedType: item.data.details?.type,
234      }, this.root.harness);
235    }
236    this.reporter[kEmitMessage](item.type, item.data);
237  }
238  #accumulateReportItem(item) {
239    if (item.type !== 'test:pass' && item.type !== 'test:fail') {
240      return;
241    }
242    this.#reportedChildren++;
243    if (item.data.nesting === 0 && item.type === 'test:fail') {
244      this.failedSubtests = true;
245    }
246  }
247  #drainReportBuffer() {
248    if (this.#reportBuffer.length > 0) {
249      ArrayPrototypeForEach(this.#reportBuffer, (ast) => this.#handleReportItem(ast));
250      this.#reportBuffer = [];
251    }
252  }
253  addToReport(item) {
254    this.#accumulateReportItem(item);
255    if (!this.isClearToSend()) {
256      ArrayPrototypePush(this.#reportBuffer, item);
257      return;
258    }
259    this.#drainReportBuffer();
260    this.#handleReportItem(item);
261  }
262  reportStarted() {}
263  drain() {
264    this.#drainRawBuffer();
265    this.#drainReportBuffer();
266  }
267  report() {
268    this.drain();
269    const skipReporting = this.#skipReporting();
270    if (!skipReporting) {
271      super.reportStarted();
272      super.report();
273    }
274  }
275  parseMessage(readData) {
276    let dataLength = TypedArrayPrototypeGetLength(readData);
277    if (dataLength === 0) return;
278    const partialV8Header = readData[dataLength - 1] === v8Header[0];
279
280    if (partialV8Header) {
281      // This will break if v8Header length (2 bytes) is changed.
282      // However it is covered by tests.
283      readData = TypedArrayPrototypeSubarray(readData, 0, dataLength - 1);
284      dataLength--;
285    }
286
287    if (this.#rawBuffer[0] && TypedArrayPrototypeGetLength(this.#rawBuffer[0]) < kSerializedSizeHeader) {
288      this.#rawBuffer[0] = Buffer.concat([this.#rawBuffer[0], readData]);
289    } else {
290      ArrayPrototypePush(this.#rawBuffer, readData);
291    }
292    this.#rawBufferSize += dataLength;
293    this.#proccessRawBuffer();
294
295    if (partialV8Header) {
296      ArrayPrototypePush(this.#rawBuffer, TypedArrayPrototypeSubarray(v8Header, 0, 1));
297      this.#rawBufferSize++;
298    }
299  }
300  #drainRawBuffer() {
301    while (this.#rawBuffer.length > 0) {
302      this.#proccessRawBuffer();
303    }
304  }
305  #proccessRawBuffer() {
306    // This method is called when it is known that there is at least one message
307    let bufferHead = this.#rawBuffer[0];
308    let headerIndex = bufferHead.indexOf(v8Header);
309    let nonSerialized = Buffer.alloc(0);
310
311    while (bufferHead && headerIndex !== 0) {
312      const nonSerializedData = headerIndex === -1 ?
313        bufferHead :
314        bufferHead.slice(0, headerIndex);
315      nonSerialized = Buffer.concat([nonSerialized, nonSerializedData]);
316      this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerializedData);
317      if (headerIndex === -1) {
318        ArrayPrototypeShift(this.#rawBuffer);
319      } else {
320        this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex);
321      }
322      bufferHead = this.#rawBuffer[0];
323      headerIndex = bufferHead?.indexOf(v8Header);
324    }
325
326    if (TypedArrayPrototypeGetLength(nonSerialized) > 0) {
327      this.addToReport({
328        __proto__: null,
329        type: 'test:stdout',
330        data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') },
331      });
332    }
333
334    while (bufferHead?.length >= kSerializedSizeHeader) {
335      // We call `readUInt32BE` manually here, because this is faster than first converting
336      // it to a buffer and using `readUInt32BE` on that.
337      const fullMessageSize = (
338        bufferHead[kV8HeaderLength] << 24 |
339        bufferHead[kV8HeaderLength + 1] << 16 |
340        bufferHead[kV8HeaderLength + 2] << 8 |
341        bufferHead[kV8HeaderLength + 3]
342      ) + kSerializedSizeHeader;
343
344      if (this.#rawBufferSize < fullMessageSize) break;
345
346      const concatenatedBuffer = this.#rawBuffer.length === 1 ?
347        this.#rawBuffer[0] : Buffer.concat(this.#rawBuffer, this.#rawBufferSize);
348
349      const deserializer = new DefaultDeserializer(
350        TypedArrayPrototypeSubarray(concatenatedBuffer, kSerializedSizeHeader, fullMessageSize),
351      );
352
353      bufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
354      this.#rawBufferSize = TypedArrayPrototypeGetLength(bufferHead);
355      this.#rawBuffer = this.#rawBufferSize !== 0 ? [bufferHead] : [];
356
357      deserializer.readHeader();
358      const item = deserializer.readValue();
359      this.addToReport(item);
360    }
361  }
362}
363
364function runTestFile(path, filesWatcher, opts) {
365  const watchMode = filesWatcher != null;
366  const subtest = opts.root.createSubtest(FileTest, path, async (t) => {
367    const args = getRunArgs(path, opts);
368    const stdio = ['pipe', 'pipe', 'pipe'];
369    const env = { __proto__: null, ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
370    if (watchMode) {
371      stdio.push('ipc');
372      env.WATCH_REPORT_DEPENDENCIES = '1';
373    }
374    if (opts.root.harness.shouldColorizeTestFiles) {
375      env.FORCE_COLOR = '1';
376    }
377
378    const child = spawn(process.execPath, args, { __proto__: null, signal: t.signal, encoding: 'utf8', env, stdio });
379    if (watchMode) {
380      filesWatcher.runningProcesses.set(path, child);
381      filesWatcher.watcher.watchChildProcessModules(child, path);
382    }
383
384    let err;
385
386
387    child.on('error', (error) => {
388      err = error;
389    });
390
391    child.stdout.on('data', (data) => {
392      subtest.parseMessage(data);
393    });
394
395    const rl = createInterface({ __proto__: null, input: child.stderr });
396    rl.on('line', (line) => {
397      if (isInspectorMessage(line)) {
398        process.stderr.write(line + '\n');
399        return;
400      }
401
402      // stderr cannot be treated as TAP, per the spec. However, we want to
403      // surface stderr lines to improve the DX. Inject each line into the
404      // test output as an unknown token as if it came from the TAP parser.
405      subtest.addToReport({
406        __proto__: null,
407        type: 'test:stderr',
408        data: { __proto__: null, file: path, message: line + '\n' },
409      });
410    });
411
412    const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
413      once(child, 'exit', { __proto__: null, signal: t.signal }),
414      finished(child.stdout, { __proto__: null, signal: t.signal }),
415    ]);
416
417    if (watchMode) {
418      filesWatcher.runningProcesses.delete(path);
419      filesWatcher.runningSubtests.delete(path);
420      if (filesWatcher.runningSubtests.size === 0) {
421        opts.root.reporter[kEmitMessage]('test:watch:drained');
422      }
423    }
424
425    if (code !== 0 || signal !== null) {
426      if (!err) {
427        const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
428        err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
429          __proto__: null,
430          exitCode: code,
431          signal: signal,
432          // The stack will not be useful since the failures came from tests
433          // in a child process.
434          stack: undefined,
435        });
436      }
437
438      throw err;
439    }
440  });
441  return subtest.start();
442}
443
444function watchFiles(testFiles, opts) {
445  const runningProcesses = new SafeMap();
446  const runningSubtests = new SafeMap();
447  const watcher = new FilesWatcher({ __proto__: null, debounce: 200, mode: 'filter', signal: opts.signal });
448  const filesWatcher = { __proto__: null, watcher, runningProcesses, runningSubtests };
449
450  watcher.on('changed', ({ owners }) => {
451    watcher.unfilterFilesOwnedBy(owners);
452    PromisePrototypeThen(SafePromiseAllReturnVoid(testFiles, async (file) => {
453      if (!owners.has(file)) {
454        return;
455      }
456      const runningProcess = runningProcesses.get(file);
457      if (runningProcess) {
458        runningProcess.kill();
459        await once(runningProcess, 'exit');
460      }
461      if (!runningSubtests.size) {
462        // Reset the topLevel counter
463        opts.root.harness.counters.topLevel = 0;
464      }
465      await runningSubtests.get(file);
466      runningSubtests.set(file, runTestFile(file, filesWatcher, opts));
467    }, undefined, (error) => {
468      triggerUncaughtException(error, true /* fromPromise */);
469    }));
470  });
471  if (opts.signal) {
472    kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
473    opts.signal.addEventListener(
474      'abort',
475      () => opts.root.postRun(),
476      { __proto__: null, once: true, [kResistStopPropagation]: true },
477    );
478  }
479
480  return filesWatcher;
481}
482
483function run(options) {
484  if (options === null || typeof options !== 'object') {
485    options = kEmptyObject;
486  }
487  let { testNamePatterns, shard } = options;
488  const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options;
489
490  if (files != null) {
491    validateArray(files, 'options.files');
492  }
493  if (watch != null) {
494    validateBoolean(watch, 'options.watch');
495  }
496  if (only != null) {
497    validateBoolean(only, 'options.only');
498  }
499  if (shard != null) {
500    validateObject(shard, 'options.shard');
501    // Avoid re-evaluating the shard object in case it's a getter
502    shard = { __proto__: null, index: shard.index, total: shard.total };
503
504    validateInteger(shard.total, 'options.shard.total', 1);
505    validateInteger(shard.index, 'options.shard.index');
506
507    if (shard.index <= 0 || shard.total < shard.index) {
508      throw new ERR_OUT_OF_RANGE('options.shard.index', `>= 1 && <= ${shard.total} ("options.shard.total")`, shard.index);
509    }
510
511    if (watch) {
512      throw new ERR_INVALID_ARG_VALUE('options.shard', watch, 'shards not supported with watch mode');
513    }
514  }
515  if (setup != null) {
516    validateFunction(setup, 'options.setup');
517  }
518  if (testNamePatterns != null) {
519    if (!ArrayIsArray(testNamePatterns)) {
520      testNamePatterns = [testNamePatterns];
521    }
522
523    testNamePatterns = ArrayPrototypeMap(testNamePatterns, (value, i) => {
524      if (isRegExp(value)) {
525        return value;
526      }
527      const name = `options.testNamePatterns[${i}]`;
528      if (typeof value === 'string') {
529        return convertStringToRegExp(value, name);
530      }
531      throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
532    });
533  }
534
535  const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
536  let testFiles = files ?? createTestFileList();
537
538  if (shard) {
539    testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
540  }
541
542  let postRun = () => root.postRun();
543  let filesWatcher;
544  const opts = { __proto__: null, root, signal, inspectPort, testNamePatterns, only };
545  if (watch) {
546    filesWatcher = watchFiles(testFiles, opts);
547    postRun = undefined;
548  }
549  const runFiles = () => {
550    root.harness.bootstrapComplete = true;
551    return SafePromiseAllSettledReturnVoid(testFiles, (path) => {
552      const subtest = runTestFile(path, filesWatcher, opts);
553      filesWatcher?.runningSubtests.set(path, subtest);
554      return subtest;
555    });
556  };
557
558  PromisePrototypeThen(PromisePrototypeThen(PromiseResolve(setup?.(root)), runFiles), postRun);
559
560  return root.reporter;
561}
562
563module.exports = {
564  FileTest, // Exported for tests only
565  run,
566};
567