Skip to content

Commit 249c6fc

Browse files
committed
test_runner: add option to rerun only failed tests
1 parent 48aa9c7 commit 249c6fc

File tree

10 files changed

+172
-2
lines changed

10 files changed

+172
-2
lines changed

doc/api/cli.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2647,6 +2647,18 @@ changes:
26472647
The destination for the corresponding test reporter. See the documentation on
26482648
[test reporters][] for more details.
26492649

2650+
### `--test-rerun=path`
2651+
2652+
<!-- YAML
2653+
added:
2654+
- REPLACEME
2655+
-->
2656+
2657+
A path to a file storing the results of a previous test run. The test runner
2658+
will use this file to determine which tests to re-run, allowing for
2659+
re-running of failed tests without having to re-run the entire test suite.
2660+
The test runner will create this file if it does not exist.
2661+
26502662
### `--test-shard`
26512663

26522664
<!-- YAML
@@ -3508,6 +3520,7 @@ one is included in the list below.
35083520
* `--test-only`
35093521
* `--test-reporter-destination`
35103522
* `--test-reporter`
3523+
* `--test-rerun`
35113524
* `--test-shard`
35123525
* `--test-skip-pattern`
35133526
* `--throw-deprecation`

doc/api/test.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ test('skip() method with message', (t) => {
153153
});
154154
```
155155

156+
## Rerunning failed tests
157+
158+
The test runner supports rerunning failed tests by passing the [`--test-rerun`][]
159+
command-line option. This option takes a file path as an argument, which is used
160+
to store the state of the run, allowing the test runner to rerun only the failed
161+
tests.
162+
163+
```bash
164+
node --test-rerun /path/to/state/file
165+
```
166+
156167
## TODO tests
157168

158169
Individual tests can be marked as flaky or incomplete by passing the `todo`
@@ -3929,6 +3940,7 @@ Can be used to abort test subtasks when the test has been aborted.
39293940
[`--test-only`]: cli.md#--test-only
39303941
[`--test-reporter-destination`]: cli.md#--test-reporter-destination
39313942
[`--test-reporter`]: cli.md#--test-reporter
3943+
[`--test-rerun`]: cli.md#--test-rerun
39323944
[`--test-skip-pattern`]: cli.md#--test-skip-pattern
39333945
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
39343946
[`--test`]: cli.md#--test
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypePush,
5+
JSONStringify,
6+
} = primordials;
7+
const { writeFileSync } = require('fs');
8+
9+
function reportReruns(previousRuns, rerunFilePath) {
10+
return async function reporter(source) {
11+
const obj = { __proto__: null };
12+
13+
for await (const { type, data } of source) {
14+
if (type === 'test:pass') {
15+
obj[`${data.file}:${data.line}:${data.column}`] = data.details.passed_attempt ?? data.details.attempt;
16+
}
17+
}
18+
19+
ArrayPrototypePush(previousRuns, obj);
20+
writeFileSync(rerunFilePath, JSONStringify(previousRuns, null, 2), 'utf8');
21+
};
22+
};
23+
24+
module.exports = {
25+
__proto__: null,
26+
reportReruns,
27+
};

lib/internal/test_runner/runner.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ function getRunArgs(path, { forceExit,
148148
only,
149149
argv: suppliedArgs,
150150
execArgv,
151+
rerunFilePath,
151152
root: { timeout },
152153
cwd }) {
153154
const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
@@ -169,6 +170,9 @@ function getRunArgs(path, { forceExit,
169170
if (timeout != null) {
170171
ArrayPrototypePush(argv, `--test-timeout=${timeout}`);
171172
}
173+
if (rerunFilePath != null) {
174+
ArrayPrototypePush(argv, `--test-rerun=${rerunFilePath}`);
175+
}
172176

173177
ArrayPrototypePushApply(argv, execArgv);
174178

lib/internal/test_runner/test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ class TestContext {
290290
return this.#test.passed;
291291
}
292292

293+
get attempt() {
294+
return this.#test.attempt ?? 0;
295+
}
296+
293297
diagnostic(message) {
294298
this.#test.diagnostic(message);
295299
}
@@ -646,6 +650,8 @@ class Test extends AsyncResource {
646650
this.endTime = null;
647651
this.passed = false;
648652
this.error = null;
653+
this.attempt = undefined;
654+
this.passedAttempt = undefined;
649655
this.message = typeof skip === 'string' ? skip :
650656
typeof todo === 'string' ? todo : null;
651657
this.activeSubtests = 0;
@@ -690,6 +696,16 @@ class Test extends AsyncResource {
690696
this.loc.file = fileURLToPath(this.loc.file);
691697
}
692698
}
699+
700+
if (this.loc != null && this.config.previousRuns != null) {
701+
const testLocation = `${this.loc.file}:${this.loc.line}:${this.loc.column}`;
702+
this.attempt = this.config.previousRuns.length;
703+
const previousAttempt = this.config.previousRuns[this.attempt - 1]?.[testLocation];
704+
if (previousAttempt != null) {
705+
this.passedAttempt = previousAttempt;
706+
this.fn = noop;
707+
}
708+
}
693709
}
694710

695711
applyFilters() {
@@ -1329,6 +1345,12 @@ class Test extends AsyncResource {
13291345
if (!this.passed) {
13301346
details.error = this.error;
13311347
}
1348+
if (this.attempt !== undefined) {
1349+
details.attempt = this.attempt;
1350+
}
1351+
if (this.passedAttempt !== undefined) {
1352+
details.passed_attempt = this.passedAttempt;
1353+
}
13321354
return { __proto__: null, details, directive };
13331355
}
13341356

lib/internal/test_runner/utils.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const {
88
ArrayPrototypePush,
99
ArrayPrototypeReduce,
1010
ArrayPrototypeSome,
11+
JSONParse,
1112
MathFloor,
1213
MathMax,
1314
MathMin,
@@ -28,9 +29,10 @@ const {
2829

2930
const { AsyncResource } = require('async_hooks');
3031
const { relative, sep, resolve } = require('path');
31-
const { createWriteStream } = require('fs');
32+
const { createWriteStream, readFileSync } = require('fs');
3233
const { pathToFileURL } = require('internal/url');
3334
const { getOptionValue } = require('internal/options');
35+
const { reportReruns } = require('internal/test_runner/reporter/rerun');
3436
const { green, yellow, red, white, shouldColorize } = require('internal/util/colors');
3537

3638
const {
@@ -40,7 +42,7 @@ const {
4042
},
4143
kIsNodeError,
4244
} = require('internal/errors');
43-
const { compose } = require('stream');
45+
const { compose, PassThrough } = require('stream');
4446
const {
4547
validateInteger,
4648
validateFunction,
@@ -150,6 +152,20 @@ function shouldColorizeTestFiles(destinations) {
150152
});
151153
}
152154

155+
function parsePreviousRuns(rerunFilePath) {
156+
let data;
157+
try {
158+
data = readFileSync(rerunFilePath, 'utf8');
159+
} catch (err) {
160+
if (err.code === 'ENOENT') {
161+
data = '[]';
162+
} else {
163+
throw err;
164+
}
165+
}
166+
return JSONParse(data);
167+
}
168+
153169
async function getReportersMap(reporters, destinations) {
154170
return SafePromiseAllReturnArrayLike(reporters, async (name, i) => {
155171
const destination = kBuiltinDestinations.get(destinations[i]) ??
@@ -202,6 +218,7 @@ function parseCommandLine() {
202218
const updateSnapshots = getOptionValue('--test-update-snapshots');
203219
const watch = getOptionValue('--watch');
204220
const timeout = getOptionValue('--test-timeout') || Infinity;
221+
const rerunFilePath = getOptionValue('--test-rerun');
205222
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
206223
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
207224
let globalSetupPath;
@@ -308,8 +325,24 @@ function parseCommandLine() {
308325
validateInteger(functionCoverage, '--test-coverage-functions', 0, 100);
309326
}
310327

328+
let previousRuns;
329+
if (rerunFilePath) {
330+
validatePath(rerunFilePath, '--test-rerun');
331+
previousRuns = parsePreviousRuns(rerunFilePath);
332+
if (previousRuns === null) {
333+
throw new ERR_INVALID_ARG_VALUE('--test-rerun', rerunFilePath, 'is not a valid rerun file');
334+
}
335+
}
336+
311337
const setup = reporterScope.bind(async (rootReporter) => {
312338
const reportersMap = await getReportersMap(reporters, destinations);
339+
if (previousRuns && rerunFilePath) {
340+
ArrayPrototypePush(reportersMap, {
341+
__proto__: null,
342+
reporter: reportReruns(previousRuns, rerunFilePath),
343+
destination: new PassThrough(),
344+
});
345+
}
313346

314347
for (let i = 0; i < reportersMap.length; i++) {
315348
const { reporter, destination } = reportersMap[i];
@@ -343,6 +376,8 @@ function parseCommandLine() {
343376
timeout,
344377
updateSnapshots,
345378
watch,
379+
rerunFilePath,
380+
previousRuns,
346381
};
347382

348383
return globalTestOptions;
@@ -637,4 +672,5 @@ module.exports = {
637672
shouldColorizeTestFiles,
638673
getCoverageReport,
639674
setupGlobalSetupTeardownFunctions,
675+
parsePreviousRuns,
640676
};

src/node_options.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
918918
&EnvironmentOptions::test_global_setup_path,
919919
kAllowedInEnvvar,
920920
OptionNamespaces::kTestRunnerNamespace);
921+
AddOption("--test-rerun",
922+
"report test output using the given reporter",
923+
&EnvironmentOptions::test_rerun,
924+
kAllowedInEnvvar,
925+
OptionNamespaces::kTestRunnerNamespace);
921926
AddOption("--test-udp-no-try-send",
922927
"", // For testing only.
923928
&EnvironmentOptions::test_udp_no_try_send,

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ class EnvironmentOptions : public Options {
198198
bool test_runner_update_snapshots = false;
199199
std::vector<std::string> test_name_pattern;
200200
std::vector<std::string> test_reporter;
201+
std::string test_rerun;
201202
std::vector<std::string> test_reporter_destination;
202203
std::string test_global_setup_path;
203204
bool test_only = false;

test/fixtures/test-runner/rerun.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const { test } = require('node:test')
2+
3+
test('should fail on first two attempts', ({ attempt }) => {
4+
if (attempt < 2) {
5+
throw new Error('This test is expected to fail on the first two attempts');
6+
}
7+
});
8+
9+
test('ok', ({ attempt }) => {
10+
if (attempt > 0) {
11+
throw new Error('Test should not rerun once it has passed');
12+
}
13+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
const common = require('../common');
3+
4+
const fixtures = require('../common/fixtures');
5+
const assert = require('node:assert');
6+
const { rm } = require('node:fs/promises');
7+
const { test, beforeEach, afterEach } = require('node:test');
8+
9+
const stateFile = fixtures.path('test-runner', 'rerun-state.json');
10+
11+
beforeEach(() => rm(stateFile, { force: true }));
12+
afterEach(() => rm(stateFile, { force: true }));
13+
14+
test('test should pass on third rerun', async () => {
15+
const cwd = fixtures.path('test-runner');
16+
const fixture = fixtures.path('test-runner', 'rerun.js');
17+
const args = ['--test-rerun', stateFile, fixture];
18+
19+
let { code, stdout, signal } = await common.spawnPromisified(process.execPath, args);
20+
assert.strictEqual(code, 1);
21+
assert.strictEqual(signal, null);
22+
assert.match(stdout, /pass 1/);
23+
assert.match(stdout, /fail 1/);
24+
25+
({ code, stdout, signal } = await common.spawnPromisified(process.execPath, args, { cwd }));
26+
assert.strictEqual(code, 1);
27+
assert.strictEqual(signal, null);
28+
assert.match(stdout, /pass 1/);
29+
assert.match(stdout, /fail 1/);
30+
31+
32+
({ code, stdout, signal } = await common.spawnPromisified(process.execPath, args, { cwd }));
33+
assert.strictEqual(code, 0);
34+
assert.strictEqual(signal, null);
35+
assert.match(stdout, /pass 2/);
36+
assert.match(stdout, /fail 0/);
37+
});

0 commit comments

Comments
 (0)