| // Code coverage | |
| // | |
| // Collects this process's V8 coverage, and, for a test that reports, runs coverage.js on | |
| // exit to turn the collected profiles into an lcov report. | |
| // | |
| // We enable collection via the inspector rather than by setting the NODE_V8_COVERAGE | |
| // environment variable. This allows us to start collection in-process without relying on any | |
| // shell script logic to run beforehand. | |
| // | |
| // The report is generated here because the test action is the only place the profiles and | |
| // the instrumented sources are both present, and in its own process because coverage.js is | |
| // async, chdir()s, and would otherwise land in this process's own profile. See #2901. | |
| const { logError } = require('./util.cjs') | |
| const collecting = startCollection() | |
| // Child node processes re-enter bootstrap.cjs with an inherited environment. Only the root | |
| // process reports, once every child has exited and written its profile. | |
| const { JS_BINARY__COVERAGE_REPORT } = process.env | |
| delete process.env.JS_BINARY__COVERAGE_REPORT | |
| // The exit hook runs coverage.js, the executable that turns the V8 profiles in COVERAGE_DIR | |
| // into the lcov report the _lcov_merger publishes. | |
| if (JS_BINARY__COVERAGE_REPORT) { | |
| // Snapshot the environment and cwd now: the program under test may mutate either before it | |
| // exits, and the reporter must see what the launcher set up. | |
| // | |
| // NODE_V8_COVERAGE is blanked so the reporter writes no profile of its own alongside the | |
| // ones it reads. It has to be emptied rather than deleted: child_process always propagates | |
| // NODE_V8_COVERAGE from the parent, and only skips doing so when the env passed to it has | |
| // the key as a property. | |
| const env = { | |
| ...process.env, | |
| JS_COVERAGE__RUNFILES: process.env.JS_BINARY__RUNFILES, | |
| NODE_V8_COVERAGE: '', | |
| } | |
| const cwd = process.cwd() | |
| // Not process.execPath, which bootstrap.cjs patched to the node wrapper; that would | |
| // re-apply the bootstrap in the reporter process. | |
| const nodeBinary = process.env.JS_BINARY__NODE_BINARY | |
| const report = require('node:path').resolve( | |
| process.env.JS_BINARY__RUNFILES, | |
| JS_BINARY__COVERAGE_REPORT | |
| ) | |
| // This listener is registered by a --require preload, so it runs before any | |
| // 'exit' listener the program itself registers. takeCoverage() below | |
| // therefore snapshots the profile before those listeners run, and code the | |
| // program executes from one of them is reported as uncovered. | |
| process.on('exit', function onExitCoverageReport() { | |
| try { | |
| const fs = require('node:fs') | |
| const { spawnSync } = require('node:child_process') | |
| // A test reporting its own coverage owns it, except under split | |
| // post-processing, where bazel drops what the test wrote. | |
| if ( | |
| env.SPLIT_COVERAGE_POST_PROCESSING !== '1' && | |
| env.COVERAGE_OUTPUT_FILE | |
| ) { | |
| const stat = fs.statSync(env.COVERAGE_OUTPUT_FILE, { | |
| throwIfNoEntry: false, | |
| }) | |
| if (stat && stat.size > 0) return | |
| } | |
| if (!fs.existsSync(report)) { | |
| // Report empty coverage rather than fail an otherwise passing test. | |
| logError( | |
| `coverage report generator '${report}' not found; code coverage requires a runfiles tree` | |
| ) | |
| return | |
| } | |
| // When we collect, our own profile is already on disk: startCollection | |
| // registered its exit listener before this one. node instead writes it during | |
| // teardown, after this handler, so ask for it here. Deliberately no | |
| // v8.stopCoverage(): node's teardown asks V8 for coverage regardless, and | |
| // errors out once it has stopped. | |
| if (!collecting) { | |
| require('node:v8').takeCoverage() | |
| } | |
| const { status, error } = spawnSync(nodeBinary, [report], { | |
| cwd, | |
| env, | |
| stdio: 'inherit', | |
| }) | |
| if (error || status !== 0) { | |
| throw error || new Error(`exit code ${status}`) | |
| } | |
| } catch (e) { | |
| // Throwing here would skip every later 'exit' listener, including the program's own. | |
| logErrorAndFail(`coverage report generation failed: ${e.message}`) | |
| } | |
| }) | |
| } | |
| // Starts a V8 precise coverage session for this process, unless node is already collecting. | |
| // Returns whether we are the collector. | |
| function startCollection() { | |
| if (!process.env.COVERAGE_DIR || process.env.NODE_V8_COVERAGE) { | |
| return false | |
| } | |
| const path = require('node:path') | |
| const { mkdirSync, writeFileSync } = require('node:fs') | |
| let session | |
| // session.post() reports a command failure through its callback and drops it entirely | |
| // without one. The callback is synchronous, which is both why this can rethrow and why | |
| // flush() below can run from an exit listener. | |
| const post = (method, params) => { | |
| let failure, result | |
| session.post(method, params, (err, res) => { | |
| failure = err | |
| result = res | |
| }) | |
| if (failure) { | |
| throw failure | |
| } | |
| return result | |
| } | |
| // A node without inspector support, or a program that already holds the session, | |
| // makes this throw. Report empty coverage rather than take down a program that would | |
| // otherwise have run: this preload is in every js_binary under `bazel coverage`, so a | |
| // throw here fails the target before its own code starts. | |
| try { | |
| session = new (require('node:inspector').Session)() | |
| session.connect() | |
| post('Profiler.enable') | |
| post('Profiler.startPreciseCoverage', { | |
| callCount: true, | |
| detailed: true, | |
| }) | |
| } catch (e) { | |
| logError(`v8 coverage collection unavailable: ${e.message}`) | |
| return false | |
| } | |
| // Resolve now: bootstrap.cjs may chdir this process later, and node itself resolves | |
| // NODE_V8_COVERAGE against the directory it started in. | |
| const dir = path.resolve(process.env.COVERAGE_DIR) | |
| // Enable coverage for all child processes. | |
| process.env.NODE_V8_COVERAGE = dir | |
| function flush() { | |
| try { | |
| const { result } = post('Profiler.takePreciseCoverage') | |
| // The name node itself uses: pid, timestamp, thread id. Only one collector | |
| // ever runs in a thread, so no two can pick the same name. | |
| const { threadId } = require('node:worker_threads') | |
| const now = Date.now() | |
| mkdirSync(dir, { recursive: true }) | |
| writeFileSync( | |
| path.join(dir, `coverage-${process.pid}-${now}-${threadId}.json`), | |
| JSON.stringify({ | |
| result, | |
| timestamp: now, | |
| 'source-map-cache': sourceMapCache(result), | |
| }) | |
| ) | |
| } catch (e) { | |
| logErrorAndFail(`v8 coverage collection failed: ${e.message}`) | |
| } | |
| session.disconnect() | |
| } | |
| process.on('exit', flush) | |
| return true | |
| } | |
| // The source maps node recorded for the scripts in this profile, in the shape node writes | |
| // alongside its own: {<script url>: {data, lineLengths}}. The reporter needs them to map V8 | |
| // offsets in generated code back to the original source. It re-reads a //# sourceMappingURL | |
| // from disk on its own, so this only matters for a runtime transpiler such as ts-node, which | |
| // compiles from memory and leaves nothing on disk to read. | |
| // | |
| // node populates this cache as it compiles each module, gated on NODE_V8_COVERAGE, which | |
| // startCollection sets above before any of the program is compiled. | |
| function sourceMapCache(result) { | |
| const { findSourceMap } = require('node:module') | |
| const cache = {} | |
| for (const { url } of result) { | |
| // Skip node's own builtins, and anything not compiled from a file. | |
| if (!url?.startsWith('file://')) { | |
| continue | |
| } | |
| try { | |
| const map = findSourceMap(url) | |
| if (map) { | |
| cache[url] = { data: map.payload, lineLengths: map.lineLengths } | |
| } | |
| } catch { | |
| // A script whose map cannot be recovered is reported unmapped, as it would | |
| // have been without this. | |
| } | |
| } | |
| return cache | |
| } | |
| // Fail the target, without masking an exit code the program has already chosen. | |
| function logErrorAndFail(message) { | |
| logError(message) | |
| if (!process.exitCode) { | |
| process.exitCode = 1 | |
| } | |
| } |