feat(coverage): Add V8 code coverage collection#150
Conversation
When NODE_V8_COVERAGE env var is set, the SDK starts a tiny HTTP server on TUSK_COVERAGE_PORT (default 19876) that calls v8.takeCoverage() on GET /snapshot. This allows the CLI to take V8 coverage snapshots between tests for per-test coverage attribution.
Move coverage server startup to before early returns in initialize() so it works regardless of SDK mode (RECORD/REPLAY/DISABLED). Also reverted from V8 Inspector approach back to v8.takeCoverage() after discovering Inspector's takePreciseCoverage only tracks scripts loaded AFTER startPreciseCoverage is called, missing all user code that loads at startup.
…keCoverage) Replace NYC global.__coverage__ reading with V8's built-in coverage: - v8.takeCoverage() auto-resets counters, giving clean per-test data - SDK processes V8 JSON files in-process (byte offset -> line conversion) - No external dependencies (no NYC, no c8) - Works with CJS, ESM, TypeScript - anything V8 executes - No command wrapping needed (just NODE_V8_COVERAGE env var) - Near-zero overhead (no file I/O between snapshots since SDK cleans up)
When /snapshot?baseline=true is called, includes V8 ranges with count=0 (uncovered code) in the response. This provides the denominator for coverage percentage calculation. Note: V8's takeCoverage() output only includes scripts/functions that were loaded, so truly unloaded files don't appear. This is acceptable since most Node.js servers load all source files at startup.
V8 block coverage uses nested ranges (outer = function definition, inner = branches/blocks). The innermost range gives the accurate count for each byte position. Previously we used Math.max which let the outer range's count=1 (function defined) override inner count=0 (body never called). Now in baseline mode, ranges are processed in order (outermost first) with later (more specific) ranges overwriting earlier ones. This correctly produces count=0 for uncalled function bodies inside called scopes.
Previously per-test mode summed counts from all V8 ranges covering a line. This caused outer function ranges (count=1) to override inner uncovered ranges (count=0), making catch blocks and untaken branches appear covered. Now both modes use innermost-range-wins. The only difference: - Baseline: keeps count=0 lines (for denominator) - Per-test: filters out count=0 lines (only executed code) Result: Node coverage now accurately reports uncovered error handlers and untaken branches, matching Python's coverage.py behavior. Example: server.js went from 100% to 84.7%.
Move V8 coverage logic from TuskDrift.ts closures into coverageProcessor.ts: - computeLineStarts: byte-offset-to-line mapping - offsetToLine: binary search for line number - processScriptCoverage: innermost-range-wins logic - filterScriptUrl: user source file filtering - processV8CoverageFile: full file processing with injectable reader - takeAndProcessSnapshot: high-level snapshot + cleanup Add 19 AVA tests covering: - Line start computation edge cases - Offset-to-line binary search - Innermost range wins (catch blocks, untaken branches, uncalled functions) - Baseline vs per-test mode (include/exclude count=0) - Script URL filtering (node_modules, sourceRoot) - Full file processing with mock reader
- Update CoverageResult to include per-file branch data (totalBranches, coveredBranches, per-line BranchInfo with total/covered) - Extract branch data from V8 block coverage: inner ranges (index > 0) in functions with isBlockCoverage=true represent branch points - Update tests for new FileCoverageData structure - Note: V8 branch data may be incomplete after takeCoverage() reset (isBlockCoverage becomes false for some functions)
Replace manual V8 range processing with v8-to-istanbul library: - Accurate branch detection (handles implicit taken branches that V8 doesn't create separate ranges for) - Source map support for TypeScript (v8-to-istanbul reads .map files) - Standard Istanbul format output (statementMap, branchMap, fnMap) - Branch coverage now shows real numbers: 58.8% (10/17) for demo app v8-to-istanbul is the same library used by c8, Jest, and Vitest. Note: line coverage interpretation changes slightly - Istanbul counts a statement as covered if any range covering it has count > 0 (standard behavior), vs our previous innermost-range-wins approach.
ast-v8-to-istanbul parses the source AST independently, so it correctly handles partial V8 data after v8.takeCoverage() reset. Uncalled functions (absent from V8 output) are correctly identified as uncovered. v8-to-istanbul assumed complete V8 data and marked missing functions as "covered by default", causing all 175 lines to show as covered even when only /health was called. Results with ast-v8-to-istanbul: - Lines: 85.2% (52/61) for server.js ✓ - Branches: 42.9% (6/14) - real branch coverage from AST analysis ✓ - Per-test: 4, 1, 1, 13, 5, 4 lines - accurate per-test attribution ✓ - Supports TypeScript via sourceMap option
When processing V8 coverage files, detect //# sourceMappingURL= comments, load the .map file, and pass it to ast-v8-to-istanbul. Coverage is then reported against the original .ts source files instead of compiled .js. Tested with example-express-ts-server: - Before: dist/server.js 90.2% (55/61) - After: src/server.ts 90.2% (55/61) Requirements for TypeScript projects: - sourceMap: true in tsconfig.json (generates .js.map files) - Source map files must be alongside compiled JS
- CLI sets TS_NODE_EMIT=true env var when coverage is enabled, forcing ts-node to write compiled JS + source maps to .ts-node/ directory - SDK's resolveSourceCode() checks for .ts/.tsx files and looks for compiled JS in .ts-node/ or alongside the source file - Falls back gracefully if compiled JS not found (file skipped)
Add docs/coverage.md explaining V8 coverage internals, ast-v8-to-istanbul, source map handling, multi-PID quick-scan, and limitations. Update environment-variables.md with coverage env vars section.
- Add acorn to dependencies (was only available as transitive dev dep) - Add .catch() to async coverage handler to prevent unhandled rejections - Use ecmaVersion "latest" for acorn parsing - Remove dead legacy code (computeLineStarts, offsetToLine, processScriptCoverage) - Fix branchLine null check (was producing "undefined" string key) - Add eslint-disable comments for intentional lazy require() calls - Remove unused counter variables - Use const for coverage result - Simplify resolveSourcePath (remove unused parameter) - Use projectRoot consistently instead of process.cwd() - URL decode file paths in filterScriptUrl - Generalize sourceRoot handling for /, relative, and absolute paths - Fix double JSON parse (pass pre-parsed data from quick-scan)
src/instrumentation/libraries/ioredis/e2e-tests/cjs-ioredis/BUG_TRACKING.md
Outdated
Show resolved
Hide resolved
- Use OriginalGlobalUtils.getOriginalProcessEnvVar for NODE_V8_COVERAGE (process.env may be instrumented in replay mode) - Change || to ?? for numeric defaults (project convention) - Remove accidentally committed BUG_TRACKING.md files
There was a problem hiding this comment.
2 issues found across 8 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/core/coverageProcessor.ts">
<violation number="1" location="src/core/coverageProcessor.ts:166">
P1: The `.ts-node` candidate path is constructed incorrectly (absolute segment + `.ts.js` suffix), so emitted TS output is not found and TS coverage can be skipped.</violation>
<violation number="2" location="src/core/coverageProcessor.ts:409">
P2: Using `Math.max` for `existing.covered` during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| const existing = coverage[filePath].branches[line]; | ||
| if (existing) { | ||
| existing.total = Math.max(existing.total, branchInfo.total); | ||
| existing.covered = Math.max(existing.covered, branchInfo.covered); |
There was a problem hiding this comment.
P2: Using Math.max for existing.covered during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/coverageProcessor.ts, line 409:
<comment>Using `Math.max` for `existing.covered` during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.</comment>
<file context>
@@ -0,0 +1,432 @@
+ const existing = coverage[filePath].branches[line];
+ if (existing) {
+ existing.total = Math.max(existing.total, branchInfo.total);
+ existing.covered = Math.max(existing.covered, branchInfo.covered);
+ } else {
+ coverage[filePath].branches[line] = { ...branchInfo };
</file context>
The first candidate for finding compiled JS in .ts-node/ was constructing paths like server.ts.js instead of server.js. This caused ts-node coverage to silently fail (falling back to raw TS which acorn can't parse).
…lisions /app was matching /application. Now checks for trailing / or exact match, same pattern as Python SDK's _is_user_file.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/core/coverageProcessor.ts">
<violation number="1" location="src/core/coverageProcessor.ts:74">
P2: The new path-boundary check rejects all files when `sourceRoot` is `/`, causing coverage to be empty in root-working-directory environments.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
When sourceRoot is "/" (common in Docker), sourceRoot + "/" becomes "//" which matches nothing. Skip the boundary check when sourceRoot is root.
…fset alignment Node.js wraps CJS modules with a 62-byte function header. V8 coverage byte offsets include this wrapper, but our AST is parsed from the raw source. Without passing wrapperLength, every line/branch coverage mapping was shifted by ~62 bytes for CJS modules.
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/core/coverageProcessor.ts">
<violation number="1" location="src/core/coverageProcessor.ts:237">
P1: `isCJS` is inferred from parse success in `script` mode, which misclassifies valid ESM files and applies `wrapperLength` incorrectly.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| sourceType: "script", | ||
| locations: true, | ||
| }); | ||
| isCJS = true; |
There was a problem hiding this comment.
P1: isCJS is inferred from parse success in script mode, which misclassifies valid ESM files and applies wrapperLength incorrectly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/coverageProcessor.ts, line 237:
<comment>`isCJS` is inferred from parse success in `script` mode, which misclassifies valid ESM files and applies `wrapperLength` incorrectly.</comment>
<file context>
@@ -224,14 +224,17 @@ export async function processV8CoverageFile(
sourceType: "script",
locations: true,
});
+ isCJS = true;
} catch {
ast = acorn.parse(code, {
</file context>
Generated 11 tests - 11 passedTip New to Tusk Unit Tests? Learn more here. Test Summary
ResultsTusk's tests all pass and validate the critical path for V8 coverage collection end-to-end. The View check history
Was Tusk helpful? Give feedback by reacting with 👍 or 👎 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4997321. Configure here.
| sourceType: "module", | ||
| locations: true, | ||
| }); | ||
| } |
There was a problem hiding this comment.
CJS misdetection silently corrupts ESM file coverage offsets
Medium Severity
The CJS vs ESM detection relies on whether acorn successfully parses with sourceType: "script". ESM files that lack static import/export statements (e.g., files in a "type": "module" package using only dynamic import(), or simple utility files) parse as valid scripts, causing isCJS to be true. This incorrectly applies the ~62-byte CJS wrapper offset via require("module").wrapper[0].length, silently shifting all V8 coverage byte positions and producing wrong line/branch mappings for the entire file.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4997321. Configure here.
| * identifies ALL functions/branches even when V8 only reports a subset | ||
| * (e.g., after v8.takeCoverage() reset). Missing functions = uncovered. | ||
| */ | ||
| export async function processV8CoverageFile( |
There was a problem hiding this comment.
Exported function only used within same file
Low Severity
processV8CoverageFile is exported but never imported outside coverageProcessor.ts — it's only called internally by takeAndProcessSnapshot. The export keyword unnecessarily widens the public API surface of this module.
Reviewed by Cursor Bugbot for commit 4997321. Configure here.




Add V8 code coverage collection to the Node.js SDK. When the CLI enables coverage, the SDK processes V8 coverage data and returns per-file line/branch coverage via protobuf.
How it works
NODE_V8_COVERAGEenv var → V8 enables precise coverageCoverageSnapshotRequestvia protobuf channelv8.takeCoverage()(writes JSON, resets counters)ast-v8-to-istanbulfor accurate line/branch coverageCoverageSnapshotResponsewith per-file dataKey design decisions
require()only when coverage is enabled, avoiding startup cost for 99% of users.NODE_V8_COVERAGEis inherited by all child processes (npm, tsc, server). The SDK quick-scans each V8 JSON to skip non-server files before expensive AST processing..js.mapfiles, fixes TypeScript'ssourceRoot: "/"issue, strips sourceMappingURL to prevent double-loading.Files changed
src/core/coverageProcessor.ts— V8 coverage processing, source map handling, quick-scansrc/core/coverageProcessor.test.ts— filterScriptUrl testssrc/core/ProtobufCommunicator.ts— Coverage snapshot request handlerpackage.json— Added acorn, ast-v8-to-istanbul dependenciesdocs/coverage.md— SDK coverage internals documentationdocs/environment-variables.md— Coverage env vars sectionTODOs before merge
Blocked on feat(proto): Add coverage tracking messages tusk-drift-schemas#47—@use-tusk/drift-schemasupdated to ^0.1.34Add test for URL-encoded paths in filterScriptUrldata:URIs) for esbuild/tsx usersEdge cases / gotchas
sourceRoot: "/"break ast-v8-to-istanbul's path resolution. The SDK strips sourceRoot and resolves sources relative to the project root.sourceType: "script"first, falls back to"module". Handles both without config.