test_runner: add 'test:summary' event

This commit adds a new 'test:summary' event to the test runner's
reporting interface. This new event serves two purposes:

- In the future, the test runner internals will no longer need to
  change the process exit code. This may be important to run()
  users. Unfortunately, this is a breaking change, so it needs to
  be changed in a major version.
- The reporting interface now has a single event that can identify
  passing or failing test runs.

Refs: https://github.com/nodejs/node/issues/53867
Refs: https://github.com/nodejs/node/issues/54812
PR-URL: https://github.com/nodejs/node/pull/54851
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Colin Ihrig 2024-09-21 13:41:52 -04:00 committed by GitHub
parent 5a966714c1
commit 059e08bb21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 62 additions and 12 deletions

View File

@ -3042,6 +3042,31 @@ This event is only emitted if `--test` flag is passed.
This event is not guaranteed to be emitted in the same order as the tests are
defined.
### Event: `'test:summary'`
* `data` {Object}
* `counts` {Object} An object containing the counts of various test results.
* `cancelled` {number} The total number of cancelled tests.
* `failed` {number} The total number of failed tests.
* `passed` {number} The total number of passed tests.
* `skipped` {number} The total number of skipped tests.
* `suites` {number} The total number of suites run.
* `tests` {number} The total number of tests run, excluding suites.
* `todo` {number} The total number of TODO tests.
* `topLevel` {number} The total number of top level tests and suites.
* `duration_ms` {number} The duration of the test run in milliseconds.
* `file` {string|undefined} The path of the test file that generated the
summary. If the summary corresponds to multiple files, this value is
`undefined`.
* `success` {boolean} Indicates whether or not the test run is considered
successful or not. If any error condition occurs, such as a failing test or
unmet coverage threshold, this value will be set to `false`.
Emitted when a test run completes. This event contains metrics pertaining to
the completed test run, and is useful for determining if a test run passed or
failed. If process-level test isolation is used, a `'test:summary'` event is
generated for each test file in addition to a final cumulative summary.
### Event: `'test:watch:drained'`
Emitted when no more tests are queued for execution in watch mode.

View File

@ -31,8 +31,8 @@ if (isUsingInspector() && options.isolation === 'process') {
options.globPatterns = ArrayPrototypeSlice(process.argv, 1);
debug('test runner configuration:', options);
run(options).on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
run(options).on('test:summary', (data) => {
if (!data.success) {
process.exitCode = kGenericUserError;
}
});

View File

@ -52,7 +52,7 @@ function createTestTree(rootTestOptions, globalOptions) {
resetCounters() {
harness.counters = {
__proto__: null,
all: 0,
tests: 0,
failed: 0,
passed: 0,
cancelled: 0,
@ -62,6 +62,7 @@ function createTestTree(rootTestOptions, globalOptions) {
suites: 0,
};
},
success: true,
counters: null,
shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations),
teardown: null,
@ -130,6 +131,7 @@ function createProcessEventHandler(eventName, rootTest) {
}
rootTest.diagnostic(msg);
rootTest.harness.success = false;
process.exitCode = kGenericUserError;
return;
}
@ -152,6 +154,7 @@ function configureCoverage(rootTest, globalOptions) {
const msg = `Warning: Code coverage could not be enabled. ${err}`;
rootTest.diagnostic(msg);
rootTest.harness.success = false;
process.exitCode = kGenericUserError;
}
}
@ -167,6 +170,7 @@ function collectCoverage(rootTest, coverage) {
summary = coverage.summary();
} catch (err) {
rootTest.diagnostic(`Warning: Could not report code coverage. ${err}`);
rootTest.harness.success = false;
process.exitCode = kGenericUserError;
}
@ -174,6 +178,7 @@ function collectCoverage(rootTest, coverage) {
coverage.cleanup();
} catch (err) {
rootTest.diagnostic(`Warning: Could not clean up code coverage. ${err}`);
rootTest.harness.success = false;
process.exitCode = kGenericUserError;
}
@ -248,14 +253,16 @@ function lazyBootstrapRoot() {
if (!globalRoot) {
// This is where the test runner is bootstrapped when node:test is used
// without the --test flag or the run() API.
const entryFile = process.argv?.[1];
const rootTestOptions = {
__proto__: null,
entryFile: process.argv?.[1],
entryFile,
loc: entryFile ? [1, 1, entryFile] : undefined,
};
const globalOptions = parseCommandLine();
createTestTree(rootTestOptions, globalOptions);
globalRoot.reporter.on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
globalRoot.reporter.on('test:summary', (data) => {
if (!data.success) {
process.exitCode = kGenericUserError;
}
});

View File

@ -1043,14 +1043,15 @@ class Test extends AsyncResource {
reporter.diagnostic(nesting, loc, diagnostics[i]);
}
reporter.diagnostic(nesting, loc, `tests ${harness.counters.all}`);
const duration = this.duration();
reporter.diagnostic(nesting, loc, `tests ${harness.counters.tests}`);
reporter.diagnostic(nesting, loc, `suites ${harness.counters.suites}`);
reporter.diagnostic(nesting, loc, `pass ${harness.counters.passed}`);
reporter.diagnostic(nesting, loc, `fail ${harness.counters.failed}`);
reporter.diagnostic(nesting, loc, `cancelled ${harness.counters.cancelled}`);
reporter.diagnostic(nesting, loc, `skipped ${harness.counters.skipped}`);
reporter.diagnostic(nesting, loc, `todo ${harness.counters.todo}`);
reporter.diagnostic(nesting, loc, `duration_ms ${this.duration()}`);
reporter.diagnostic(nesting, loc, `duration_ms ${duration}`);
if (coverage) {
const coverages = [
@ -1067,6 +1068,7 @@ class Test extends AsyncResource {
for (let i = 0; i < coverages.length; i++) {
const { threshold, actual, name } = coverages[i];
if (actual < threshold) {
harness.success = false;
process.exitCode = kGenericUserError;
reporter.diagnostic(nesting, loc, `Error: ${NumberPrototypeToFixed(actual, 2)}% ${name} coverage does not meet threshold of ${threshold}%.`);
}
@ -1075,6 +1077,10 @@ class Test extends AsyncResource {
reporter.coverage(nesting, loc, coverage);
}
reporter.summary(
nesting, loc?.file, harness.success, harness.counters, duration,
);
if (harness.watching) {
this.reported = false;
harness.resetCounters();

View File

@ -132,6 +132,16 @@ class TestsStream extends Readable {
});
}
summary(nesting, file, success, counts, duration_ms) {
this[kEmitMessage]('test:summary', {
__proto__: null,
success,
counts,
duration_ms,
file,
});
}
end() {
this.#tryPush(null);
}

View File

@ -357,12 +357,14 @@ function countCompletedTest(test, harness = test.root.harness) {
harness.counters.todo++;
} else if (test.cancelled) {
harness.counters.cancelled++;
harness.success = false;
} else if (!test.passed) {
harness.counters.failed++;
harness.success = false;
} else {
harness.counters.passed++;
}
harness.counters.all++;
harness.counters.tests++;
}

View File

@ -113,7 +113,7 @@ describe('node:test reporters', { concurrency: true }, () => {
testFile]);
assert.strictEqual(child.stderr.toString(), '');
const stdout = child.stdout.toString();
assert.match(stdout, /{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:diagnostic":\d+}$/);
assert.match(stdout, /{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:summary":2,"test:diagnostic":\d+}$/);
assert.strictEqual(stdout.slice(0, filename.length + 2), `${filename} {`);
});
});
@ -125,7 +125,7 @@ describe('node:test reporters', { concurrency: true }, () => {
assert.strictEqual(child.stderr.toString(), '');
assert.match(
child.stdout.toString(),
/^package: reporter-cjs{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:diagnostic":\d+}$/,
/^package: reporter-cjs{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:summary":2,"test:diagnostic":\d+}$/,
);
});
@ -136,7 +136,7 @@ describe('node:test reporters', { concurrency: true }, () => {
assert.strictEqual(child.stderr.toString(), '');
assert.match(
child.stdout.toString(),
/^package: reporter-esm{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:diagnostic":\d+}$/,
/^package: reporter-esm{"test:enqueue":5,"test:dequeue":5,"test:complete":5,"test:start":4,"test:pass":2,"test:fail":2,"test:plan":2,"test:summary":2,"test:diagnostic":\d+}$/,
);
});