Skip to content

Commit 9a0dad2

Browse files
Benjamin Coetargos
Benjamin Coe
authored andcommitted
coverage: expose native V8 coverage
native V8 coverage reports can now be written to disk by setting the variable NODE_V8_COVERAGE=dir PR-URL: #22527 Reviewed-By: Anna Henningsen <[email protected]> Reviewed-By: Yang Guo <[email protected]> Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Evan Lucas <[email protected]> Reviewed-By: Rod Vagg <[email protected]>
1 parent 1025868 commit 9a0dad2

14 files changed

+264
-0
lines changed

doc/api/cli.md

+28
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,32 @@ Path to the file used to store the persistent REPL history. The default path is
625625
`~/.node_repl_history`, which is overridden by this variable. Setting the value
626626
to an empty string (`''` or `' '`) disables persistent REPL history.
627627

628+
### `NODE_V8_COVERAGE=dir`
629+
630+
When set, Node.js will begin outputting [V8 JavaScript code coverage][] to the
631+
directory provided as an argument. Coverage is output as an array of
632+
[ScriptCoverage][] objects:
633+
634+
```json
635+
{
636+
"result": [
637+
{
638+
"scriptId": "67",
639+
"url": "internal/tty.js",
640+
"functions": []
641+
}
642+
]
643+
}
644+
```
645+
646+
`NODE_V8_COVERAGE` will automatically propagate to subprocesses, making it
647+
easier to instrument applications that call the `child_process.spawn()` family
648+
of functions. `NODE_V8_COVERAGE` can be set to an empty string, to prevent
649+
propagation.
650+
651+
At this time coverage is only collected in the main thread and will not be
652+
output for code executed by worker threads.
653+
628654
### `OPENSSL_CONF=file`
629655
<!-- YAML
630656
added: v6.11.0
@@ -691,6 +717,8 @@ greater than `4` (its current default value). For more information, see the
691717
[`process.setUncaughtExceptionCaptureCallback()`]: process.html#process_process_setuncaughtexceptioncapturecallback_fn
692718
[Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/
693719
[REPL]: repl.html
720+
[ScriptCoverage]: https://chromedevtools.github.io/devtools-protocol/tot/Profiler#type-ScriptCoverage
721+
[V8 JavaScript code coverage]: https://v8project.blogspot.com/2017/12/javascript-code-coverage.html
694722
[debugger]: debugger.html
695723
[emit_warning]: process.html#process_process_emitwarning_warning_type_code_ctor
696724
[experimental ECMAScript Module]: esm.html#esm_loader_hooks

lib/child_process.js

+8
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,14 @@ function normalizeSpawnArguments(file, args, options) {
496496
var env = options.env || process.env;
497497
var envPairs = [];
498498

499+
// process.env.NODE_V8_COVERAGE always propagates, making it possible to
500+
// collect coverage for programs that spawn with white-listed environment.
501+
if (process.env.NODE_V8_COVERAGE &&
502+
!Object.prototype.hasOwnProperty.call(options.env || {},
503+
'NODE_V8_COVERAGE')) {
504+
env.NODE_V8_COVERAGE = process.env.NODE_V8_COVERAGE;
505+
}
506+
499507
// Prototype values are intentionally included.
500508
for (var key in env) {
501509
const value = env[key];

lib/internal/bootstrap/node.js

+6
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@
9898
if (global.__coverage__)
9999
NativeModule.require('internal/process/write-coverage').setup();
100100

101+
if (process.env.NODE_V8_COVERAGE) {
102+
const { resolve } = NativeModule.require('path');
103+
process.env.NODE_V8_COVERAGE = resolve(process.env.NODE_V8_COVERAGE);
104+
NativeModule.require('internal/process/coverage').setup();
105+
}
106+
101107

102108
{
103109
const traceEvents = process.binding('trace_events');

lib/internal/print_help.js

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const envVars = new Map([
2929
'of stderr' }],
3030
['NODE_REPL_HISTORY', { helpText: 'path to the persistent REPL ' +
3131
'history file' }],
32+
['NODE_V8_COVERAGE', { helpText: 'directory to output v8 coverage JSON ' +
33+
'to' }],
3234
['OPENSSL_CONF', { helpText: 'load OpenSSL configuration from file' }]
3335
].concat(hasIntl ? [
3436
['NODE_ICU_DATA', { helpText: 'data path for ICU (Intl object) data' +

lib/internal/process/coverage.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
const path = require('path');
3+
const { mkdirSync, writeFileSync } = require('fs');
4+
// TODO(addaleax): add support for coverage to worker threads.
5+
const hasInspector = process.config.variables.v8_enable_inspector === 1 &&
6+
require('internal/worker').isMainThread;
7+
let inspector = null;
8+
if (hasInspector) inspector = require('inspector');
9+
10+
let session;
11+
12+
function writeCoverage() {
13+
if (!session) {
14+
return;
15+
}
16+
17+
const filename = `coverage-${process.pid}-${Date.now()}.json`;
18+
try {
19+
// TODO(bcoe): switch to mkdirp once #22302 is addressed.
20+
mkdirSync(process.env.NODE_V8_COVERAGE);
21+
} catch (err) {
22+
if (err.code !== 'EEXIST') {
23+
console.error(err);
24+
return;
25+
}
26+
}
27+
28+
const target = path.join(process.env.NODE_V8_COVERAGE, filename);
29+
30+
try {
31+
session.post('Profiler.takePreciseCoverage', (err, coverageInfo) => {
32+
if (err) return console.error(err);
33+
try {
34+
writeFileSync(target, JSON.stringify(coverageInfo));
35+
} catch (err) {
36+
console.error(err);
37+
}
38+
});
39+
} catch (err) {
40+
console.error(err);
41+
} finally {
42+
session.disconnect();
43+
session = null;
44+
}
45+
}
46+
47+
exports.writeCoverage = writeCoverage;
48+
49+
function setup() {
50+
if (!hasInspector) {
51+
console.warn('coverage currently only supported in main thread');
52+
return;
53+
}
54+
55+
session = new inspector.Session();
56+
session.connect();
57+
session.post('Profiler.enable');
58+
session.post('Profiler.startPreciseCoverage', { callCount: true,
59+
detailed: true });
60+
61+
const reallyReallyExit = process.reallyExit;
62+
63+
process.reallyExit = function(code) {
64+
writeCoverage();
65+
reallyReallyExit(code);
66+
};
67+
68+
process.on('exit', writeCoverage);
69+
}
70+
71+
exports.setup = setup;

lib/internal/process/per_thread.js

+4
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ function setupKillAndExit() {
172172

173173
process.kill = function(pid, sig) {
174174
var err;
175+
if (process.env.NODE_V8_COVERAGE) {
176+
const { writeCoverage } = require('internal/process/coverage');
177+
writeCoverage();
178+
}
175179

176180
// eslint-disable-next-line eqeqeq
177181
if (pid != (pid | 0)) {

node.gyp

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
'lib/internal/process/worker_thread_only.js',
144144
'lib/internal/querystring.js',
145145
'lib/internal/process/write-coverage.js',
146+
'lib/internal/process/coverage.js',
146147
'lib/internal/readline.js',
147148
'lib/internal/repl.js',
148149
'lib/internal/repl/await.js',

test/fixtures/v8-coverage/basic.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const a = 99;
2+
if (true) {
3+
const b = 101;
4+
} else {
5+
const c = 102;
6+
}

test/fixtures/v8-coverage/exit-1.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const a = 99;
2+
if (true) {
3+
const b = 101;
4+
} else {
5+
const c = 102;
6+
}
7+
process.exit(1);

test/fixtures/v8-coverage/sigint.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const a = 99;
2+
if (true) {
3+
const b = 101;
4+
} else {
5+
const c = 102;
6+
}
7+
process.kill(process.pid, "SIGINT");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { spawnSync } = require('child_process');
2+
const env = Object.assign({}, process.env, { NODE_V8_COVERAGE: '' });
3+
spawnSync(process.execPath, [require.resolve('./subprocess')], {
4+
env: env
5+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const { spawnSync } = require('child_process');
2+
const env = Object.assign({}, process.env);
3+
delete env.NODE_V8_COVERAGE
4+
spawnSync(process.execPath, [require.resolve('./subprocess')], {
5+
env: env
6+
});
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const a = 99;
2+
setTimeout(() => {
3+
if (false) {
4+
const b = 101;
5+
} else if (false) {
6+
const c = 102;
7+
}
8+
}, 10);

test/parallel/test-v8-coverage.js

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use strict';
2+
3+
if (!process.config.variables.v8_enable_inspector) return;
4+
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const fs = require('fs');
8+
const path = require('path');
9+
const { spawnSync } = require('child_process');
10+
11+
const tmpdir = require('../common/tmpdir');
12+
tmpdir.refresh();
13+
14+
let dirc = 0;
15+
function nextdir() {
16+
return `cov_${++dirc}`;
17+
}
18+
19+
// outputs coverage when event loop is drained, with no async logic.
20+
{
21+
const coverageDirectory = path.join(tmpdir.path, nextdir());
22+
const output = spawnSync(process.execPath, [
23+
require.resolve('../fixtures/v8-coverage/basic')
24+
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
25+
assert.strictEqual(output.status, 0);
26+
const fixtureCoverage = getFixtureCoverage('basic.js', coverageDirectory);
27+
assert.ok(fixtureCoverage);
28+
// first branch executed.
29+
assert.strictEqual(fixtureCoverage.functions[1].ranges[0].count, 1);
30+
// second branch did not execute.
31+
assert.strictEqual(fixtureCoverage.functions[1].ranges[1].count, 0);
32+
}
33+
34+
// outputs coverage when process.exit(1) exits process.
35+
{
36+
const coverageDirectory = path.join(tmpdir.path, nextdir());
37+
const output = spawnSync(process.execPath, [
38+
require.resolve('../fixtures/v8-coverage/exit-1')
39+
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
40+
assert.strictEqual(output.status, 1);
41+
const fixtureCoverage = getFixtureCoverage('exit-1.js', coverageDirectory);
42+
assert.ok(fixtureCoverage, 'coverage not found for file');
43+
// first branch executed.
44+
assert.strictEqual(fixtureCoverage.functions[1].ranges[0].count, 1);
45+
// second branch did not execute.
46+
assert.strictEqual(fixtureCoverage.functions[1].ranges[1].count, 0);
47+
}
48+
49+
// outputs coverage when process.kill(process.pid, "SIGINT"); exits process.
50+
{
51+
const coverageDirectory = path.join(tmpdir.path, nextdir());
52+
const output = spawnSync(process.execPath, [
53+
require.resolve('../fixtures/v8-coverage/sigint')
54+
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
55+
if (!common.isWindows) {
56+
assert.strictEqual(output.signal, 'SIGINT');
57+
}
58+
const fixtureCoverage = getFixtureCoverage('sigint.js', coverageDirectory);
59+
assert.ok(fixtureCoverage);
60+
// first branch executed.
61+
assert.strictEqual(fixtureCoverage.functions[1].ranges[0].count, 1);
62+
// second branch did not execute.
63+
assert.strictEqual(fixtureCoverage.functions[1].ranges[1].count, 0);
64+
}
65+
66+
// outputs coverage from subprocess.
67+
{
68+
const coverageDirectory = path.join(tmpdir.path, nextdir());
69+
const output = spawnSync(process.execPath, [
70+
require.resolve('../fixtures/v8-coverage/spawn-subprocess')
71+
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
72+
assert.strictEqual(output.status, 0);
73+
const fixtureCoverage = getFixtureCoverage('subprocess.js',
74+
coverageDirectory);
75+
assert.ok(fixtureCoverage);
76+
// first branch executed.
77+
assert.strictEqual(fixtureCoverage.functions[2].ranges[0].count, 1);
78+
// second branch did not execute.
79+
assert.strictEqual(fixtureCoverage.functions[2].ranges[1].count, 0);
80+
}
81+
82+
// does not output coverage if NODE_V8_COVERAGE is empty.
83+
{
84+
const coverageDirectory = path.join(tmpdir.path, nextdir());
85+
const output = spawnSync(process.execPath, [
86+
require.resolve('../fixtures/v8-coverage/spawn-subprocess-no-cov')
87+
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
88+
assert.strictEqual(output.status, 0);
89+
const fixtureCoverage = getFixtureCoverage('subprocess.js',
90+
coverageDirectory);
91+
assert.strictEqual(fixtureCoverage, undefined);
92+
}
93+
94+
// extracts the coverage object for a given fixture name.
95+
function getFixtureCoverage(fixtureFile, coverageDirectory) {
96+
const coverageFiles = fs.readdirSync(coverageDirectory);
97+
for (const coverageFile of coverageFiles) {
98+
const coverage = require(path.join(coverageDirectory, coverageFile));
99+
for (const fixtureCoverage of coverage.result) {
100+
if (fixtureCoverage.url.indexOf(`${path.sep}${fixtureFile}`) !== -1) {
101+
return fixtureCoverage;
102+
}
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)