1import * as common from '../common/index.mjs';
2import * as fixtures from '../common/fixtures.mjs';
3import { join } from 'node:path';
4import { describe, it, run } from 'node:test';
5import { dot, spec, tap } from 'node:test/reporters';
6import assert from 'node:assert';
7
8const testFixtures = fixtures.path('test-runner');
9
10describe('require(\'node:test\').run', { concurrency: true }, () => {
11
12  it('should run with no tests', async () => {
13    const stream = run({ files: [] });
14    stream.on('test:fail', common.mustNotCall());
15    stream.on('test:pass', common.mustNotCall());
16    // eslint-disable-next-line no-unused-vars
17    for await (const _ of stream);
18  });
19
20  it('should fail with non existing file', async () => {
21    const stream = run({ files: ['a-random-file-that-does-not-exist.js'] });
22    stream.on('test:fail', common.mustCall(1));
23    stream.on('test:pass', common.mustNotCall());
24    // eslint-disable-next-line no-unused-vars
25    for await (const _ of stream);
26  });
27
28  it('should succeed with a file', async () => {
29    const stream = run({ files: [join(testFixtures, 'default-behavior/test/random.cjs')] });
30    stream.on('test:fail', common.mustNotCall());
31    stream.on('test:pass', common.mustCall(1));
32    // eslint-disable-next-line no-unused-vars
33    for await (const _ of stream);
34  });
35
36  it('should run same file twice', async () => {
37    const stream = run({
38      files: [
39        join(testFixtures, 'default-behavior/test/random.cjs'),
40        join(testFixtures, 'default-behavior/test/random.cjs'),
41      ]
42    });
43    stream.on('test:fail', common.mustNotCall());
44    stream.on('test:pass', common.mustCall(2));
45    // eslint-disable-next-line no-unused-vars
46    for await (const _ of stream);
47  });
48
49  it('should run a failed test', async () => {
50    const stream = run({ files: [testFixtures] });
51    stream.on('test:fail', common.mustCall(1));
52    stream.on('test:pass', common.mustNotCall());
53    // eslint-disable-next-line no-unused-vars
54    for await (const _ of stream);
55  });
56
57  it('should support timeout', async () => {
58    const stream = run({ timeout: 50, files: [
59      fixtures.path('test-runner', 'never_ending_sync.js'),
60      fixtures.path('test-runner', 'never_ending_async.js'),
61    ] });
62    stream.on('test:fail', common.mustCall(2));
63    stream.on('test:pass', common.mustNotCall());
64    // eslint-disable-next-line no-unused-vars
65    for await (const _ of stream);
66  });
67
68  it('should validate files', async () => {
69    [Symbol(), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve([]), true, false]
70      .forEach((files) => assert.throws(() => run({ files }), {
71        code: 'ERR_INVALID_ARG_TYPE'
72      }));
73  });
74
75  it('should be piped with dot', async () => {
76    const result = await run({
77      files: [join(testFixtures, 'default-behavior/test/random.cjs')]
78    }).compose(dot).toArray();
79    assert.deepStrictEqual(result, [
80      '.',
81      '\n',
82    ]);
83  });
84
85  describe('should be piped with spec reporter', () => {
86    it('new spec', async () => {
87      const specReporter = new spec();
88      const result = await run({
89        files: [join(testFixtures, 'default-behavior/test/random.cjs')]
90      }).compose(specReporter).toArray();
91      const stringResults = result.map((bfr) => bfr.toString());
92      assert.match(stringResults[0], /this should pass/);
93      assert.match(stringResults[1], /tests 1/);
94      assert.match(stringResults[1], /pass 1/);
95    });
96
97    it('spec()', async () => {
98      const specReporter = spec();
99      const result = await run({
100        files: [join(testFixtures, 'default-behavior/test/random.cjs')]
101      }).compose(specReporter).toArray();
102      const stringResults = result.map((bfr) => bfr.toString());
103      assert.match(stringResults[0], /this should pass/);
104      assert.match(stringResults[1], /tests 1/);
105      assert.match(stringResults[1], /pass 1/);
106    });
107  });
108
109  it('should be piped with tap', async () => {
110    const result = await run({
111      files: [join(testFixtures, 'default-behavior/test/random.cjs')]
112    }).compose(tap).toArray();
113    assert.strictEqual(result.length, 13);
114    assert.strictEqual(result[0], 'TAP version 13\n');
115    assert.strictEqual(result[1], '# Subtest: this should pass\n');
116    assert.strictEqual(result[2], 'ok 1 - this should pass\n');
117    assert.match(result[3], /duration_ms: \d+\.?\d*/);
118    assert.strictEqual(result[4], '1..1\n');
119    assert.strictEqual(result[5], '# tests 1\n');
120    assert.strictEqual(result[6], '# suites 0\n');
121    assert.strictEqual(result[7], '# pass 1\n');
122    assert.strictEqual(result[8], '# fail 0\n');
123    assert.strictEqual(result[9], '# cancelled 0\n');
124    assert.strictEqual(result[10], '# skipped 0\n');
125    assert.strictEqual(result[11], '# todo 0\n');
126    assert.match(result[12], /# duration_ms \d+\.?\d*/);
127  });
128
129  it('should skip tests not matching testNamePatterns - RegExp', async () => {
130    const result = await run({
131      files: [join(testFixtures, 'default-behavior/test/skip_by_name.cjs')],
132      testNamePatterns: [/executed/]
133    })
134      .compose(tap)
135      .toArray();
136    assert.strictEqual(result[2], 'ok 1 - this should be skipped # SKIP test name does not match pattern\n');
137    assert.strictEqual(result[5], 'ok 2 - this should be executed\n');
138  });
139
140  it('should skip tests not matching testNamePatterns - string', async () => {
141    const result = await run({
142      files: [join(testFixtures, 'default-behavior/test/skip_by_name.cjs')],
143      testNamePatterns: ['executed']
144    })
145      .compose(tap)
146      .toArray();
147    assert.strictEqual(result[2], 'ok 1 - this should be skipped # SKIP test name does not match pattern\n');
148    assert.strictEqual(result[5], 'ok 2 - this should be executed\n');
149  });
150
151  it('should pass only to children', async () => {
152    const result = await run({
153      files: [join(testFixtures, 'test_only.js')],
154      only: true
155    })
156      .compose(tap)
157      .toArray();
158
159    assert.strictEqual(result[2], 'ok 1 - this should be skipped # SKIP \'only\' option not set\n');
160    assert.strictEqual(result[5], 'ok 2 - this should be executed\n');
161  });
162
163  it('should emit "test:watch:drained" event on watch mode', async () => {
164    const controller = new AbortController();
165    await run({
166      files: [join(testFixtures, 'default-behavior/test/random.cjs')],
167      watch: true,
168      signal: controller.signal,
169    }).on('data', function({ type }) {
170      if (type === 'test:watch:drained') {
171        controller.abort();
172      }
173    });
174  });
175
176  describe('AbortSignal', () => {
177    it('should stop watch mode when abortSignal aborts', async () => {
178      const controller = new AbortController();
179      const result = await run({
180        files: [join(testFixtures, 'default-behavior/test/random.cjs')],
181        watch: true,
182        signal: controller.signal,
183      })
184        .compose(async function* (source) {
185          for await (const chunk of source) {
186            if (chunk.type === 'test:pass') {
187              controller.abort();
188              yield chunk.data.name;
189            }
190          }
191        })
192        .toArray();
193      assert.deepStrictEqual(result, ['this should pass']);
194    });
195
196    it('should abort when test succeeded', async () => {
197      const stream = run({
198        files: [
199          fixtures.path(
200            'test-runner',
201            'aborts',
202            'successful-test-still-call-abort.js'
203          ),
204        ],
205      });
206
207      let passedTestCount = 0;
208      let failedTestCount = 0;
209
210      let output = '';
211      for await (const data of stream) {
212        if (data.type === 'test:stdout') {
213          output += data.data.message.toString();
214        }
215        if (data.type === 'test:fail') {
216          failedTestCount++;
217        }
218        if (data.type === 'test:pass') {
219          passedTestCount++;
220        }
221      }
222
223      assert.match(output, /abort called for test 1/);
224      assert.match(output, /abort called for test 2/);
225      assert.strictEqual(failedTestCount, 0, new Error('no tests should fail'));
226      assert.strictEqual(passedTestCount, 2);
227    });
228
229    it('should abort when test failed', async () => {
230      const stream = run({
231        files: [
232          fixtures.path(
233            'test-runner',
234            'aborts',
235            'failed-test-still-call-abort.js'
236          ),
237        ],
238      });
239
240      let passedTestCount = 0;
241      let failedTestCount = 0;
242
243      let output = '';
244      for await (const data of stream) {
245        if (data.type === 'test:stdout') {
246          output += data.data.message.toString();
247        }
248        if (data.type === 'test:fail') {
249          failedTestCount++;
250        }
251        if (data.type === 'test:pass') {
252          passedTestCount++;
253        }
254      }
255
256      assert.match(output, /abort called for test 1/);
257      assert.match(output, /abort called for test 2/);
258      assert.strictEqual(passedTestCount, 0, new Error('no tests should pass'));
259      assert.strictEqual(failedTestCount, 2);
260    });
261  });
262
263  describe('sharding', () => {
264    const shardsTestsFixtures = fixtures.path('test-runner', 'shards');
265    const shardsTestsFiles = [
266      'a.cjs',
267      'b.cjs',
268      'c.cjs',
269      'd.cjs',
270      'e.cjs',
271      'f.cjs',
272      'g.cjs',
273      'h.cjs',
274      'i.cjs',
275      'j.cjs',
276    ].map((file) => join(shardsTestsFixtures, file));
277
278    describe('validation', () => {
279      it('should require shard.total when having shard option', () => {
280        assert.throws(() => run({ files: shardsTestsFiles, shard: {} }), {
281          name: 'TypeError',
282          code: 'ERR_INVALID_ARG_TYPE',
283          message: 'The "options.shard.total" property must be of type number. Received undefined'
284        });
285      });
286
287      it('should require shard.index when having shards option', () => {
288        assert.throws(() => run({
289          files: shardsTestsFiles,
290          shard: {
291            total: 5
292          }
293        }), {
294          name: 'TypeError',
295          code: 'ERR_INVALID_ARG_TYPE',
296          message: 'The "options.shard.index" property must be of type number. Received undefined'
297        });
298      });
299
300      it('should require shard.total to be greater than 0 when having shard option', () => {
301        assert.throws(() => run({
302          files: shardsTestsFiles,
303          shard: {
304            total: 0,
305            index: 1
306          }
307        }), {
308          name: 'RangeError',
309          code: 'ERR_OUT_OF_RANGE',
310          message:
311            'The value of "options.shard.total" is out of range. It must be >= 1 && <= 9007199254740991. Received 0'
312        });
313      });
314
315      it('should require shard.index to be greater than 0 when having shard option', () => {
316        assert.throws(() => run({
317          files: shardsTestsFiles,
318          shard: {
319            total: 6,
320            index: 0
321          }
322        }), {
323          name: 'RangeError',
324          code: 'ERR_OUT_OF_RANGE',
325          // eslint-disable-next-line max-len
326          message: 'The value of "options.shard.index" is out of range. It must be >= 1 && <= 6 ("options.shard.total"). Received 0'
327        });
328      });
329
330      it('should require shard.index to not be greater than the shards total when having shard option', () => {
331        assert.throws(() => run({
332          files: shardsTestsFiles,
333          shard: {
334            total: 6,
335            index: 7
336          }
337        }), {
338          name: 'RangeError',
339          code: 'ERR_OUT_OF_RANGE',
340          // eslint-disable-next-line max-len
341          message: 'The value of "options.shard.index" is out of range. It must be >= 1 && <= 6 ("options.shard.total"). Received 7'
342        });
343      });
344
345      it('should require watch mode to be disabled when having shard option', () => {
346        assert.throws(() => run({
347          files: shardsTestsFiles,
348          watch: true,
349          shard: {
350            total: 6,
351            index: 1
352          }
353        }), {
354          name: 'TypeError',
355          code: 'ERR_INVALID_ARG_VALUE',
356          message: 'The property \'options.shard\' shards not supported with watch mode. Received true'
357        });
358      });
359    });
360
361    it('should run only the tests files matching the shard index', async () => {
362      const stream = run({
363        files: shardsTestsFiles,
364        shard: {
365          total: 5,
366          index: 1
367        }
368      });
369
370      const executedTestFiles = [];
371      stream.on('test:fail', common.mustNotCall());
372      stream.on('test:pass', (passedTest) => {
373        executedTestFiles.push(passedTest.file);
374      });
375      // eslint-disable-next-line no-unused-vars
376      for await (const _ of stream) ;
377
378      assert.deepStrictEqual(executedTestFiles, [
379        join(shardsTestsFixtures, 'a.cjs'),
380        join(shardsTestsFixtures, 'f.cjs'),
381      ]);
382    });
383
384    it('different shards should not run the same file', async () => {
385      const executedTestFiles = [];
386
387      const testStreams = [];
388      const shards = 5;
389      for (let i = 1; i <= shards; i++) {
390        const stream = run({
391          files: shardsTestsFiles,
392          shard: {
393            total: shards,
394            index: i
395          }
396        });
397        stream.on('test:fail', common.mustNotCall());
398        stream.on('test:pass', (passedTest) => {
399          executedTestFiles.push(passedTest.file);
400        });
401        testStreams.push(stream);
402      }
403
404      await Promise.all(testStreams.map(async (stream) => {
405        // eslint-disable-next-line no-unused-vars
406        for await (const _ of stream) ;
407      }));
408
409      assert.deepStrictEqual(executedTestFiles, [...new Set(executedTestFiles)]);
410    });
411
412    it('combination of all shards should be all the tests', async () => {
413      const executedTestFiles = [];
414
415      const testStreams = [];
416      const shards = 5;
417      for (let i = 1; i <= shards; i++) {
418        const stream = run({
419          files: shardsTestsFiles,
420          shard: {
421            total: shards,
422            index: i
423          }
424        });
425        stream.on('test:fail', common.mustNotCall());
426        stream.on('test:pass', (passedTest) => {
427          executedTestFiles.push(passedTest.file);
428        });
429        testStreams.push(stream);
430      }
431
432      await Promise.all(testStreams.map(async (stream) => {
433        // eslint-disable-next-line no-unused-vars
434        for await (const _ of stream) ;
435      }));
436
437      assert.deepStrictEqual(executedTestFiles.sort(), [...shardsTestsFiles].sort());
438    });
439  });
440});
441