Skip to content

Commit 509dc02

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

File tree

12 files changed

+218
-2
lines changed

12 files changed

+218
-2
lines changed

doc/api/cli.md

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

2650+
### `--test-rerun`
2651+
2652+
<!-- YAML
2653+
added:
2654+
- REPLACEME
2655+
-->
2656+
2657+
A path to a file allowing the test runner to persist the state of the test
2658+
suite between runs. The test runner will use this file to determine which tests
2659+
have already succeeded or failed, allowing for re-running of failed tests
2660+
without having to re-run the entire test suite. The test runner will create this
2661+
file if it does not exist.
2662+
See the documentation on [test reruns][] for more details.
2663+
26502664
### `--test-shard`
26512665

26522666
<!-- YAML
@@ -3508,6 +3522,7 @@ one is included in the list below.
35083522
* `--test-only`
35093523
* `--test-reporter-destination`
35103524
* `--test-reporter`
3525+
* `--test-rerun`
35113526
* `--test-shard`
35123527
* `--test-skip-pattern`
35133528
* `--throw-deprecation`
@@ -4082,6 +4097,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
40824097
[snapshot testing]: test.md#snapshot-testing
40834098
[syntax detection]: packages.md#syntax-detection
40844099
[test reporters]: test.md#test-reporters
4100+
[test reruns]: test.md#rerunning-failed-tests
40854101
[test runner execution model]: test.md#test-runner-execution-model
40864102
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
40874103
[tracking issue for user-land snapshots]: https://github.com/nodejs/node/issues/44014

doc/api/test.md

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

156+
## Rerunning failed tests
157+
158+
The test runner supports persisting the state of the run to a file, allowing
159+
the test runner to rerun failed tests without having to re-run the entire test suite.
160+
Use the [`--test-rerun`][] command-line option to specify a file path where the
161+
state of the run is stored. if the state file does not exist, the test runner will
162+
create it.
163+
the state file is a JSON file that contains an array of run attempts.
164+
Each run attempt is an object mapping successful tests to the attempt they have passed in.
165+
The key of a test in this map is the test file path, and the line and column where the test is defined,
166+
meaning the state file is valid as long as the test definition does not change.
167+
168+
example of a state file:
169+
170+
```json
171+
[
172+
{
173+
"test.js:10:5": 0,
174+
},
175+
{
176+
"test.js:10:5": 0,
177+
"test.js:20:5": 1
178+
}
179+
]
180+
```
181+
182+
in this example, there are two run attempts, with two tests defined in `test.js`,
183+
the first test succeeded on the first attempt, and the second test succeeded on the second attempt.
184+
185+
When the `--test-rerun` option is used, the test runner will only run tests that have not yet passed.
186+
187+
```bash
188+
node --test-rerun /path/to/state/file
189+
```
190+
156191
## TODO tests
157192

158193
Individual tests can be marked as flaky or incomplete by passing the `todo`
@@ -3202,6 +3237,8 @@ Emitted when a test is enqueued for execution.
32023237
* `cause` {Error} The actual error thrown by the test.
32033238
* `type` {string|undefined} The type of the test, used to denote whether
32043239
this is a suite.
3240+
* `attempt` {number|undefined} The attempt number of the test run,
3241+
present only when using the [`--test-rerun`][] flag.
32053242
* `file` {string|undefined} The path of the test file,
32063243
`undefined` if test was run through the REPL.
32073244
* `line` {number|undefined} The line number where the test is defined, or
@@ -3226,6 +3263,10 @@ The corresponding execution ordered event is `'test:complete'`.
32263263
* `duration_ms` {number} The duration of the test in milliseconds.
32273264
* `type` {string|undefined} The type of the test, used to denote whether
32283265
this is a suite.
3266+
* `attempt` {number|undefined} The attempt number of the test run,
3267+
present only when using the [`--test-rerun`][] flag.
3268+
* `passed_attempt` {number|undefined} The attempt number the test passed on,
3269+
present only when using the [`--test-rerun`][] flag.
32293270
* `file` {string|undefined} The path of the test file,
32303271
`undefined` if test was run through the REPL.
32313272
* `line` {number|undefined} The line number where the test is defined, or
@@ -3929,6 +3970,7 @@ Can be used to abort test subtasks when the test has been aborted.
39293970
[`--test-only`]: cli.md#--test-only
39303971
[`--test-reporter-destination`]: cli.md#--test-reporter-destination
39313972
[`--test-reporter`]: cli.md#--test-reporter
3973+
[`--test-rerun`]: cli.md#--test-rerun
39323974
[`--test-skip-pattern`]: cli.md#--test-skip-pattern
39333975
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
39343976
[`--test`]: cli.md#--test

doc/node-config-schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,9 @@
446446
}
447447
]
448448
},
449+
"test-rerun": {
450+
"type": "string"
451+
},
449452
"test-shard": {
450453
"type": "string"
451454
},
@@ -698,6 +701,9 @@
698701
}
699702
]
700703
},
704+
"test-rerun": {
705+
"type": "string"
706+
},
701707
"test-shard": {
702708
"type": "string"
703709
},

doc/node.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,10 @@ A test reporter to use when running tests.
493493
.It Fl -test-reporter-destination
494494
The destination for the corresponding test reporter.
495495
.
496+
.It Fl -test-rerun
497+
Configures the tests runner to persist the state of tests to allow
498+
rerunning only failed tests.
499+
.
496500
.It Fl -test-only
497501
Configures the test runner to only execute top level tests that have the `only`
498502
option set.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypePush,
5+
JSONStringify,
6+
} = primordials;
7+
const { relative } = require('path');
8+
const { writeFileSync } = require('fs');
9+
10+
function reportReruns(previousRuns, rerunFilePath) {
11+
const cwd = process.cwd();
12+
return async function reporter(source) {
13+
const obj = { __proto__: null };
14+
15+
for await (const { type, data } of source) {
16+
if (type === 'test:pass') {
17+
obj[`${relative(cwd, data.file)}:${data.line}:${data.column}`] = data.details.passed_attempt ?? data.details.attempt;
18+
}
19+
}
20+
21+
ArrayPrototypePush(previousRuns, obj);
22+
writeFileSync(rerunFilePath, JSONStringify(previousRuns, null, 2), 'utf8');
23+
};
24+
};
25+
26+
module.exports = {
27+
__proto__: null,
28+
reportReruns,
29+
};

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const {
7171
} = require('timers');
7272
const { TIMEOUT_MAX } = require('internal/timers');
7373
const { fileURLToPath } = require('internal/url');
74+
const { relative } = require('path');
7475
const { availableParallelism } = require('os');
7576
const { innerOk } = require('internal/assert/utils');
7677
const { bigint: hrtime } = process.hrtime;
@@ -290,6 +291,10 @@ class TestContext {
290291
return this.#test.passed;
291292
}
292293

294+
get attempt() {
295+
return this.#test.attempt ?? 0;
296+
}
297+
293298
diagnostic(message) {
294299
this.#test.diagnostic(message);
295300
}
@@ -646,6 +651,8 @@ class Test extends AsyncResource {
646651
this.endTime = null;
647652
this.passed = false;
648653
this.error = null;
654+
this.attempt = undefined;
655+
this.passedAttempt = undefined;
649656
this.message = typeof skip === 'string' ? skip :
650657
typeof todo === 'string' ? todo : null;
651658
this.activeSubtests = 0;
@@ -690,6 +697,16 @@ class Test extends AsyncResource {
690697
this.loc.file = fileURLToPath(this.loc.file);
691698
}
692699
}
700+
701+
if (this.loc != null && this.config.previousRuns != null) {
702+
const testLocation = `${relative(process.cwd(), this.loc.file)}:${this.loc.line}:${this.loc.column}`;
703+
this.attempt = this.config.previousRuns.length;
704+
const previousAttempt = this.config.previousRuns[this.attempt - 1]?.[testLocation];
705+
if (previousAttempt != null) {
706+
this.passedAttempt = previousAttempt;
707+
this.fn = noop;
708+
}
709+
}
693710
}
694711

695712
applyFilters() {
@@ -1329,6 +1346,12 @@ class Test extends AsyncResource {
13291346
if (!this.passed) {
13301347
details.error = this.error;
13311348
}
1349+
if (this.attempt !== undefined) {
1350+
details.attempt = this.attempt;
1351+
}
1352+
if (this.passedAttempt !== undefined) {
1353+
details.passed_attempt = this.passedAttempt;
1354+
}
13321355
return { __proto__: null, details, directive };
13331356
}
13341357

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+
"specifies the path to the rerun state file",
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;

0 commit comments

Comments
 (0)