diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000..91a64a5 --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,90 @@ +# Code Coverage (Node.js) + +The Node SDK collects per-test code coverage during Tusk Drift replay using V8's built-in precise coverage. No external dependencies like NYC or c8 are needed. + +## How It Works + +### V8 Precise Coverage + +When coverage is enabled (via `--show-coverage`, `--coverage-output`, or `coverage.enabled: true` in config), the CLI sets `NODE_V8_COVERAGE=`. This tells V8 to enable precise coverage collection internally: + +``` +V8 internally calls: Profiler.startPreciseCoverage({ callCount: true, detailed: true }) +``` + +This provides: +- **Real execution counts** (1, 2, 5...) not just binary covered/uncovered +- **Block-level granularity**: branches, loops, expressions +- **Zero external dependencies** — works with any Node.js version that supports `NODE_V8_COVERAGE` +- **Works with CJS, ESM, TypeScript, bundled code** — anything V8 executes + +### Snapshot Flow + +1. **Baseline**: After the service starts, the CLI sends a `CoverageSnapshotRequest(baseline=true)`. The SDK calls `v8.takeCoverage()`, which writes a JSON file to the `NODE_V8_COVERAGE` directory and **resets all counters**. The baseline captures all coverable lines (including uncovered at count=0) for the coverage denominator. + +2. **Per-test**: After each test, the CLI sends `CoverageSnapshotRequest(baseline=false)`. The SDK calls `v8.takeCoverage()` again. Because counters were reset, the result contains **only lines executed by this specific test** — no diffing needed. + +3. **Processing**: The SDK processes the V8 JSON using `ast-v8-to-istanbul`, which converts V8 byte ranges into Istanbul-format line/branch coverage. The result is sent back to the CLI via protobuf. + +### Why ast-v8-to-istanbul (not v8-to-istanbul) + +After `v8.takeCoverage()` resets counters, V8 only reports functions that were called since the reset. Functions that were never called are **absent** from the V8 output. + +- **v8-to-istanbul** assumes complete V8 data. Missing functions are treated as "covered by default." This produces 100% coverage for files where only `/health` was hit. +- **ast-v8-to-istanbul** parses the source file's AST independently. It knows about ALL functions from the AST and correctly marks missing ones as uncovered. + +This is the key reason we use `ast-v8-to-istanbul`. + +### Source Map Support + +For TypeScript projects using `tsc`, the SDK automatically: + +1. Detects `//# sourceMappingURL=.map` comments in compiled JS +2. Loads the `.map` file +3. Fixes `sourceRoot` if present (TypeScript sets `sourceRoot: "/"` which breaks ast-v8-to-istanbul's internal path resolution — the SDK resolves sources relative to the actual project root) +4. Strips the `sourceMappingURL` comment from code passed to ast-v8-to-istanbul (prevents it from loading the unpatched `.map` file) +5. Passes the fixed source map to ast-v8-to-istanbul, which remaps coverage to original `.ts` files + +**Requirements:** `sourceMap: true` in `tsconfig.json`. Source map files (`.js.map`) must be present alongside compiled output. + +**Supported setups:** +| Setup | Status | +|-------|--------| +| `tsc` -> `node dist/` | Works (tested) | +| `swc`/`esbuild` (compile, not bundle) -> `node dist/` | Should work (same `.js` + `.js.map` pattern) | +| `ts-node` with `TS_NODE_EMIT=true` | Works (CLI sets this automatically) | +| `ts-node-dev` | Limited — lazy compilation means only executed files have coverage | +| Bundled (webpack/esbuild/Rollup) | Untested — should work if source maps are produced | + +### Multi-PID Handling + +The start command in `.tusk/config.yaml` often chains processes: + +``` +rm -rf dist && npm run build && node dist/server.js +``` + +This creates multiple Node processes (npm, tsc, the server), all inheriting `NODE_V8_COVERAGE`. Each writes its own V8 coverage file. The SDK handles this by **quick-scanning** each file to check for user scripts before running the expensive ast-v8-to-istanbul processing. Files from npm/tsc (which have 0 user scripts) are skipped. + +### CJS vs ESM + +The SDK tries parsing source code as CJS (`sourceType: "script"`) first, falling back to ESM (`sourceType: "module"`) if that fails. This handles both module formats without configuration. + +## Environment Variables + +These are set automatically by the CLI when coverage is enabled. You should not set them manually. + +| Variable | Description | +|----------|-------------| +| `NODE_V8_COVERAGE` | Directory for V8 to write coverage JSON files. Set by CLI. | +| `TUSK_COVERAGE` | Language-agnostic signal that coverage is enabled. Set by CLI. | +| `TS_NODE_EMIT` | Forces ts-node to write compiled JS to disk (needed for coverage processing). Set by CLI. | + +## Limitations + +- **acorn parse failures**: `ast-v8-to-istanbul` uses acorn to parse JavaScript. Files using syntax acorn doesn't support (stage 3 proposals, certain decorator patterns) are silently skipped. +- **Stale `dist/` artifacts**: `tsc` doesn't clean old output files. If a source file was renamed or moved, the old compiled file remains in `dist/` and may have broken imports. Use `rm -rf dist` before `tsc` in your start command. +- **Multi-process apps**: If your app uses Node's cluster module or PM2 to fork workers, each worker is a separate process. Only the worker connected to the CLI's protobuf channel handles coverage requests. +- **Dynamic imports**: Modules loaded via dynamic `import()` after startup aren't in the baseline snapshot. Their uncovered functions won't be in the denominator. +- **Large codebases**: Processing 500+ files from a 24MB V8 JSON takes several seconds. The CLI has a 30-second timeout for coverage snapshots. +- **ts-node-dev**: Lazy compilation means only files accessed during the test are compiled and covered. Pre-compiled `tsc` output gives much better coverage. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9584f51..9dd1e58 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -149,7 +149,20 @@ TUSK_USE_RUST_CORE=1 npm start TUSK_USE_RUST_CORE=0 npm start ``` +## Coverage Variables + +These are set automatically by the CLI when `tusk drift run --coverage` is used. You should **not** set them manually. + +| Variable | Description | +|----------|-------------| +| `NODE_V8_COVERAGE` | Directory for V8 to write coverage JSON files. Enables V8 precise coverage collection. | +| `TUSK_COVERAGE` | Language-agnostic signal that coverage is enabled. Set to `true`. | +| `TS_NODE_EMIT` | Forces ts-node to write compiled JS to disk (needed for coverage processing). Set to `true`. | + +See [Coverage Guide](./coverage.md) for details on how coverage collection works. + ## Related Documentation - [Initialization Guide](./initialization.md) - SDK initialization parameters and config file settings - [Quick Start Guide](./quickstart.md) - Record and replay your first trace +- [Coverage Guide](./coverage.md) - Code coverage during test replay diff --git a/package-lock.json b/package-lock.json index 49d861b..b40735e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@use-tusk/drift-core-node": "^0.1.8", + "acorn": "^8.14.0", + "ast-v8-to-istanbul": "^1.0.0", "import-in-the-middle": "^1.14.4", "js-yaml": "^4.1.0", "jsonpath": "^1.1.1", @@ -35,7 +37,7 @@ "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "@use-tusk/drift-schemas": "^0.1.24", + "@use-tusk/drift-schemas": "^0.1.34", "ava": "^6.4.1", "axios": "^1.6.0", "eslint": "^8.57.1", @@ -1569,7 +1571,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1579,7 +1580,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2521,7 +2521,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -2945,9 +2944,9 @@ "integrity": "sha512-pgiuLdqpTPDGeCZPG+6I0HDyyKBgUVOoOiMjL4z0gvNAHwOKwvfQjlvTOWkkaEFl1FUmqJjKZhUIp09H16P/JA==" }, "node_modules/@use-tusk/drift-schemas": { - "version": "0.1.30", - "resolved": "https://registry.npmjs.org/@use-tusk/drift-schemas/-/drift-schemas-0.1.30.tgz", - "integrity": "sha512-gNU6JHqvI+BT0TvGvHjmFfPz+y4Q8vJAUdRG5mnqE6/wu+tfZla5J8nG27hd2DrpZJ3TK5kCjoG5LXlV3ftWEA==", + "version": "0.1.34", + "resolved": "https://registry.npmjs.org/@use-tusk/drift-schemas/-/drift-schemas-0.1.34.tgz", + "integrity": "sha512-bc2ighKASL89oCqtmGG2NjSNmoZCFh6HNoLFsanQrCNoUVsBmtxQcD4GWax3V6/JutE3AsKyQiXA3CkB+j+V/g==", "dev": true, "dependencies": { "@protobuf-ts/runtime": "^2.11.0", @@ -3226,6 +3225,36 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", @@ -5865,6 +5894,12 @@ "node": ">= 0.8" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", diff --git a/package.json b/package.json index 3288866..261410f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "@use-tusk/drift-schemas": "^0.1.24", + "@use-tusk/drift-schemas": "^0.1.34", "ava": "^6.4.1", "axios": "^1.6.0", "eslint": "^8.57.1", @@ -109,6 +109,8 @@ }, "dependencies": { "@use-tusk/drift-core-node": "^0.1.8", + "acorn": "^8.14.0", + "ast-v8-to-istanbul": "^1.0.0", "import-in-the-middle": "^1.14.4", "js-yaml": "^4.1.0", "jsonpath": "^1.1.1", diff --git a/src/core/ProtobufCommunicator.test.ts b/src/core/ProtobufCommunicator.test.ts new file mode 100644 index 0000000..e8beccd --- /dev/null +++ b/src/core/ProtobufCommunicator.test.ts @@ -0,0 +1,143 @@ +import test from "ava"; +import { ProtobufCommunicator } from "./ProtobufCommunicator"; +import { + SDKMessage, + MessageType, + FileCoverageData, + BranchInfo, +} from "@use-tusk/drift-schemas/core/communication"; +import net from "net"; + +// --- handleCoverageSnapshotRequest tests --- + +// Helper to create a test ProtobufCommunicator instance +function createTestCommunicator(): { + communicator: ProtobufCommunicator; + sentMessages: SDKMessage[]; + mockSocket: net.Socket; +} { + const sentMessages: SDKMessage[] = []; + const mockSocket = new net.Socket(); + + const communicator = new ProtobufCommunicator(); + + // Access private method via prototype manipulation for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (communicator as any).sendProtobufMessage = async (message: SDKMessage) => { + sentMessages.push(message); + }; + + // Set up a mock client connection + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (communicator as any).client = mockSocket; + + return { communicator, sentMessages, mockSocket }; +} + +test("handleCoverageSnapshotRequest: returns error when NODE_V8_COVERAGE not set", async (t) => { + const { communicator, sentMessages } = createTestCommunicator(); + + // Ensure NODE_V8_COVERAGE is not set + const originalEnv = process.env.NODE_V8_COVERAGE; + delete process.env.NODE_V8_COVERAGE; + + try { + // Invoke the private method via message handling + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (communicator as any).handleCoverageSnapshotRequest("test-req-1", false); + + // Check that error response was sent + t.is(sentMessages.length, 1); + t.is(sentMessages[0].type, MessageType.COVERAGE_SNAPSHOT); + t.is(sentMessages[0].payload.oneofKind, "coverageSnapshotResponse"); + if (sentMessages[0].payload.oneofKind === "coverageSnapshotResponse") { + t.false(sentMessages[0].payload.coverageSnapshotResponse.success); + t.is(sentMessages[0].payload.coverageSnapshotResponse.error, "NODE_V8_COVERAGE not set"); + } + } finally { + if (originalEnv !== undefined) { + process.env.NODE_V8_COVERAGE = originalEnv; + } + } +}); + +test("handleCoverageSnapshotRequest: handles errors during processing", async (t) => { + const originalEnv = process.env.NODE_V8_COVERAGE; + // Set to a non-existent directory to trigger error + process.env.NODE_V8_COVERAGE = "/nonexistent/coverage/dir"; + + try { + const { communicator, sentMessages } = createTestCommunicator(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (communicator as any).handleCoverageSnapshotRequest("test-req-4", false); + + // Should send error response + t.is(sentMessages.length, 1); + if (sentMessages[0].payload.oneofKind === "coverageSnapshotResponse") { + const response = sentMessages[0].payload.coverageSnapshotResponse; + t.false(response.success); + t.truthy(response.error); + t.true(response.error.length > 0); + } + } finally { + if (originalEnv !== undefined) { + process.env.NODE_V8_COVERAGE = originalEnv; + } else { + delete process.env.NODE_V8_COVERAGE; + } + } +}); + +// --- sendCoverageResponse tests --- + +test("sendCoverageResponse: creates correct message structure", async (t) => { + const { communicator, sentMessages } = createTestCommunicator(); + + const mockCoverage: Record = { + "/path/to/file.js": FileCoverageData.create({ + lines: { "1": 1, "2": 2 }, + totalBranches: 4, + coveredBranches: 2, + branches: { + "3": BranchInfo.create({ total: 2, covered: 1 }), + "5": BranchInfo.create({ total: 2, covered: 1 }), + }, + }), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (communicator as any).sendCoverageResponse("req-123", true, "", mockCoverage); + + t.is(sentMessages.length, 1); + const msg = sentMessages[0]; + + t.is(msg.type, MessageType.COVERAGE_SNAPSHOT); + t.is(msg.requestId, "req-123"); + t.is(msg.payload.oneofKind, "coverageSnapshotResponse"); + + if (msg.payload.oneofKind === "coverageSnapshotResponse") { + const response = msg.payload.coverageSnapshotResponse; + t.true(response.success); + t.is(response.error, ""); + t.deepEqual(response.coverage, mockCoverage); + } +}); + +test("sendCoverageResponse: handles error responses", async (t) => { + const { communicator, sentMessages } = createTestCommunicator(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (communicator as any).sendCoverageResponse("req-456", false, "Test error message", {}); + + t.is(sentMessages.length, 1); + const msg = sentMessages[0]; + + if (msg.payload.oneofKind === "coverageSnapshotResponse") { + const response = msg.payload.coverageSnapshotResponse; + t.false(response.success); + t.is(response.error, "Test error message"); + t.deepEqual(response.coverage, {}); + } +}); + diff --git a/src/core/ProtobufCommunicator.ts b/src/core/ProtobufCommunicator.ts index ebb2474..b24e302 100644 --- a/src/core/ProtobufCommunicator.ts +++ b/src/core/ProtobufCommunicator.ts @@ -16,6 +16,9 @@ import { InstrumentationVersionMismatchAlert, UnpatchedDependencyAlert, Runtime, + CoverageSnapshotResponse, + FileCoverageData, + BranchInfo, } from "@use-tusk/drift-schemas/core/communication"; import { context, Context, SpanKind as OtSpanKind } from "@opentelemetry/api"; import { Value } from "@use-tusk/drift-schemas/google/protobuf/struct"; @@ -684,6 +687,92 @@ try { }); } } + + // CLI-initiated: coverage snapshot request + if (message.payload.oneofKind === "coverageSnapshotRequest") { + const req = message.payload.coverageSnapshotRequest; + if (req) { + this.handleCoverageSnapshotRequest(requestId, req.baseline).catch((err) => { + logger.error("[ProtobufCommunicator] Coverage snapshot unhandled error:", err); + }); + } + return; + } + } + + /** + * Handle a coverage snapshot request from the CLI. + * Calls the coverage processor, converts result to protobuf, sends response. + */ + private async handleCoverageSnapshotRequest(requestId: string, baseline: boolean): Promise { + try { + const coverageDir = OriginalGlobalUtils.getOriginalProcessEnvVar("NODE_V8_COVERAGE"); + if (!coverageDir) { + await this.sendCoverageResponse(requestId, false, "NODE_V8_COVERAGE not set", {}); + return; + } + + // Lazy-loaded: coverageProcessor imports acorn + ast-v8-to-istanbul which are + // only needed for coverage. Avoids loading them on every SDK startup. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { takeAndProcessSnapshot } = require("./coverageProcessor") as { + takeAndProcessSnapshot: (dir: string, root: string, all: boolean) => Promise< + Record; totalBranches: number; coveredBranches: number; branches: Record }> + >; + }; + const sourceRoot = process.cwd(); + const result = await takeAndProcessSnapshot(coverageDir, sourceRoot, baseline); + + // Convert to protobuf format + const coverage: Record = {}; + for (const [filePath, fileData] of Object.entries(result)) { + const lines: Record = {}; + for (const [line, count] of Object.entries(fileData.lines)) { + lines[line] = count; + } + + const branches: Record = {}; + for (const [line, branchInfo] of Object.entries(fileData.branches || {})) { + branches[line] = BranchInfo.create({ + total: branchInfo.total, + covered: branchInfo.covered, + }); + } + + coverage[filePath] = FileCoverageData.create({ + lines, + totalBranches: fileData.totalBranches ?? 0, + coveredBranches: fileData.coveredBranches ?? 0, + branches, + }); + } + + await this.sendCoverageResponse(requestId, true, "", coverage); + } catch (err) { + logger.error("[ProtobufCommunicator] Coverage snapshot error:", err); + await this.sendCoverageResponse(requestId, false, String(err), {}); + } + } + + private async sendCoverageResponse( + requestId: string, + success: boolean, + error: string, + coverage: Record, + ): Promise { + const response = SDKMessage.create({ + type: MessageType.COVERAGE_SNAPSHOT, + requestId, + payload: { + oneofKind: "coverageSnapshotResponse", + coverageSnapshotResponse: CoverageSnapshotResponse.create({ + success, + error, + coverage, + }), + }, + }); + await this.sendProtobufMessage(response); } /** diff --git a/src/core/TuskDrift.ts b/src/core/TuskDrift.ts index d916a75..16d6b12 100644 --- a/src/core/TuskDrift.ts +++ b/src/core/TuskDrift.ts @@ -457,6 +457,9 @@ export class TuskDriftCore { return; } + // Coverage snapshot handling is done via the protobuf channel (ProtobufCommunicator). + // NODE_V8_COVERAGE env var enables V8 coverage collection at the process level. + if ( this.mode === TuskDriftMode.RECORD && this.config.recording?.export_spans && @@ -581,6 +584,10 @@ export class TuskDriftCore { this.logStartupSummary(); } + // Coverage snapshot handling is now done via the protobuf communication channel + // (ProtobufCommunicator.handleCoverageSnapshotRequest). No separate HTTP server needed. + // NODE_V8_COVERAGE env var is still required for V8 to collect coverage data. + markAppAsReady(): void { if (!this.initialized) { if (this.mode !== TuskDriftMode.DISABLED) { diff --git a/src/core/coverageProcessor.test.ts b/src/core/coverageProcessor.test.ts new file mode 100644 index 0000000..c316158 --- /dev/null +++ b/src/core/coverageProcessor.test.ts @@ -0,0 +1,82 @@ +import test from "ava"; +import { + filterScriptUrl, + takeAndProcessSnapshot, + V8CoverageData, +} from "./coverageProcessor"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +// --- filterScriptUrl --- + +test("filterScriptUrl: accepts user source files", (t) => { + t.is(filterScriptUrl("file:///project/src/app.js", "/project"), "/project/src/app.js"); +}); + +test("filterScriptUrl: rejects non-file URLs", (t) => { + t.is(filterScriptUrl("node:internal/modules", "/project"), null); + t.is(filterScriptUrl("", "/project"), null); +}); + +test("filterScriptUrl: rejects node_modules", (t) => { + t.is(filterScriptUrl("file:///project/node_modules/express/index.js", "/project"), null); +}); + +test("filterScriptUrl: rejects files outside sourceRoot", (t) => { + t.is(filterScriptUrl("file:///other/project/app.js", "/project"), null); +}); + +test("filterScriptUrl: rejects prefix collisions (e.g., /app vs /application)", (t) => { + t.is(filterScriptUrl("file:///application/src/app.js", "/app"), null); +}); + +test("filterScriptUrl: accepts files when sourceRoot is / (Docker root)", (t) => { + t.is(filterScriptUrl("file:///app/src/server.js", "/"), "/app/src/server.js"); +}); + +test("filterScriptUrl: handles URL-encoded paths (spaces)", (t) => { + t.is( + filterScriptUrl("file:///my%20project/src/app.js", "/my project"), + "/my project/src/app.js" + ); +}); + +// --- takeAndProcessSnapshot --- + +test("takeAndProcessSnapshot: skips coverage files without user scripts", async (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coverage-test-")); + const coverageDir = path.join(tmpDir, "coverage"); + fs.mkdirSync(coverageDir); + + try { + // Create coverage for node_modules only + const v8Data: V8CoverageData = { + result: [ + { + url: "file:///some/path/node_modules/pkg/index.js", + functions: [ + { + functionName: "", + ranges: [{ startOffset: 0, endOffset: 10, count: 1 }], + }, + ], + }, + ], + }; + + const coverageFile = path.join(coverageDir, "coverage-1.json"); + fs.writeFileSync(coverageFile, JSON.stringify(v8Data)); + + const result = await takeAndProcessSnapshot(coverageDir, tmpDir, false); + + // Should skip files without user scripts + t.deepEqual(result, {}); + + // File should be cleaned up + const remainingFiles = fs.readdirSync(coverageDir); + t.is(remainingFiles.length, 0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/src/core/coverageProcessor.ts b/src/core/coverageProcessor.ts new file mode 100644 index 0000000..3f8435e --- /dev/null +++ b/src/core/coverageProcessor.ts @@ -0,0 +1,446 @@ +/** + * V8 coverage data processing using ast-v8-to-istanbul. + * + * Converts raw V8 coverage JSON into per-file coverage data with accurate + * line, branch, and function coverage. ast-v8-to-istanbul parses the source + * file's AST independently, so it correctly handles partial V8 data (after + * v8.takeCoverage() reset) where uncalled functions are absent from V8 output. + * + * This is the key advantage over v8-to-istanbul (which assumes complete V8 data + * and marks missing functions as "covered by default"). + */ + +import fs from "fs"; +import path from "path"; + +/** Branch info for a single line */ +export interface BranchInfo { + total: number; + covered: number; +} + +/** Per-file coverage data including lines and branches */ +export interface FileCoverageData { + lines: Record; // lineNumber -> hitCount + totalBranches: number; + coveredBranches: number; + branches: Record; // lineNumber -> branch detail +} + +/** Coverage for all files */ +export type CoverageResult = Record; + +/** V8 coverage types */ +export interface V8CoverageRange { + startOffset: number; + endOffset: number; + count: number; +} + +export interface V8FunctionCoverage { + functionName?: string; + ranges: V8CoverageRange[]; + isBlockCoverage?: boolean; +} + +export interface V8ScriptCoverage { + scriptId?: string; + url: string; + functions: V8FunctionCoverage[]; +} + +export interface V8CoverageData { + result: V8ScriptCoverage[]; +} + +/** + * Filter a script URL to determine if it's a user source file. + */ +export function filterScriptUrl( + url: string, + sourceRoot: string, +): string | null { + if (!url || !url.startsWith("file://")) return null; + let filePath: string; + try { + filePath = new URL(url).pathname; + // Decode percent-encoded characters (e.g., spaces as %20) + filePath = decodeURIComponent(filePath); + } catch { + filePath = url.replace("file://", ""); + } + if (filePath.includes("node_modules")) return null; + // Check path boundary to avoid prefix collisions (/app matching /application). + // When sourceRoot is "/" (root dir, common in Docker), all absolute paths match. + if (sourceRoot !== "/" && !filePath.startsWith(sourceRoot + "/") && filePath !== sourceRoot) return null; + return filePath; +} + +/** + * Load a source map for a compiled file, if available. + * Checks for //# sourceMappingURL= comment and loads the .map file. + */ +function loadSourceMap( + filePath: string, + code: string, + projectRoot: string, +): Record | null { + // Look for //# sourceMappingURL= at the end of the file + const match = code.match(/\/\/[#@]\s*sourceMappingURL=(.+?)(?:\s|$)/); + if (!match) return null; + + const mapRef = match[1].trim(); + + // Skip data URIs (inline source maps) for now + if (mapRef.startsWith("data:")) return null; + + // Resolve relative to the source file + const mapPath = path.resolve(path.dirname(filePath), mapRef); + + try { + const mapData = JSON.parse(fs.readFileSync(mapPath, "utf-8")); + + // Fix: ast-v8-to-istanbul's internal path resolution breaks when sourceRoot + // is present (coverageMapData keys don't match position filenames). + // We resolve sources to real filesystem paths relative to the map file, + // then remove sourceRoot so ast-v8-to-istanbul uses simple relative paths. + if (mapData.sourceRoot && Array.isArray(mapData.sources)) { + const mapDir = path.dirname(mapPath); + let resolvedRoot: string; + + if (mapData.sourceRoot === "/") { + // TypeScript convention: "/" means project root, not filesystem root + resolvedRoot = projectRoot; + } else if (path.isAbsolute(mapData.sourceRoot)) { + resolvedRoot = mapData.sourceRoot; + } else { + // Relative sourceRoot (e.g., "./src") — resolve from map file directory + resolvedRoot = path.resolve(mapDir, mapData.sourceRoot); + } + + mapData.sources = mapData.sources.map((s: string) => { + const actualPath = path.resolve(resolvedRoot, s); + return path.relative(mapDir, actualPath); + }); + delete mapData.sourceRoot; + } + + return mapData; + } catch { + return null; + } +} + +/** + * Resolve a source path from Istanbul's remapped key to an absolute path. + * Istanbul may produce relative paths (e.g., "../src/server.ts") based on + * the source map's "sources" field. + */ +function resolveSourcePath( + istanbulKey: string, + compiledPath: string, +): string { + if (path.isAbsolute(istanbulKey)) return istanbulKey; + // Resolve relative to the compiled file's directory + return path.resolve(path.dirname(compiledPath), istanbulKey); +} + +/** + * Resolve the JavaScript source code to parse for a given file path. + * + * For .js files: reads directly from disk, checks for source maps. + * For .ts files (ts-node): V8's URL points to the .ts file, but the code + * V8 executed is compiled JS. With TS_NODE_EMIT=true, ts-node writes + * compiled JS + source maps to .ts-node/ directory. We look there. + */ +function resolveSourceCode(scriptPath: string, projectRoot: string): { + code: string; + resolvedPath: string; + sourceMap: Record | null; +} { + // For .ts/.tsx files, look for compiled JS in .ts-node/ directory + if (scriptPath.match(/\.(ts|tsx|mts|cts)$/)) { + // ts-node with TS_NODE_EMIT=true writes to .ts-node/ in the project root + // The compiled file mirrors the source path structure + const tsNodeDir = path.join(projectRoot, ".ts-node"); + // Try common ts-node output locations + const candidates = [ + path.join(tsNodeDir, scriptPath.replace(projectRoot, "").replace(/\.(ts|tsx|mts|cts)$/, ".js")), + scriptPath.replace(/\.(ts|tsx|mts|cts)$/, ".js"), // same dir, .js extension + ]; + + for (const candidate of candidates) { + try { + const code = fs.readFileSync(candidate, "utf-8"); + const sourceMap = loadSourceMap(candidate, code, projectRoot); + return { code, resolvedPath: candidate, sourceMap }; + } catch { + continue; + } + } + + // Fallback: try reading the .ts file directly (won't parse with acorn, + // but let it fail gracefully so processV8CoverageFile skips it) + const code = fs.readFileSync(scriptPath, "utf-8"); + return { code, resolvedPath: scriptPath, sourceMap: null }; + } + + // For .js files: read directly, check for source maps + const code = fs.readFileSync(scriptPath, "utf-8"); + const sourceMap = loadSourceMap(scriptPath, code, projectRoot); + return { code, resolvedPath: scriptPath, sourceMap }; +} + +/** + * Process a V8 coverage JSON file using ast-v8-to-istanbul. + * + * ast-v8-to-istanbul parses the source AST independently, so it correctly + * identifies ALL functions/branches even when V8 only reports a subset + * (e.g., after v8.takeCoverage() reset). Missing functions = uncovered. + */ +export async function processV8CoverageFile( + v8FilePath: string, + sourceRoot: string, + includeAll: boolean = false, + preParsedData?: V8CoverageData, +): Promise { + // Lazy-loaded: these are only needed when coverage is enabled, which is opt-in. + // Using require() avoids loading them on every SDK startup (adds ~50ms + memory). + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { convert } = require("ast-v8-to-istanbul"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const acorn = require("acorn"); + const data: V8CoverageData = preParsedData ?? JSON.parse(fs.readFileSync(v8FilePath, "utf-8")); + const coverage: CoverageResult = {}; + + for (const script of data.result) { + const scriptPath = filterScriptUrl(script.url, sourceRoot); + if (!scriptPath) continue; + + try { + // Resolve the actual JS code to parse. + // For TypeScript files (ts-node/tsx), V8's URL points to the .ts file, + // but the code V8 executed is compiled JS. With TS_NODE_EMIT=true, + // ts-node writes compiled JS to .ts-node/ directory. We look there first. + const { code, resolvedPath, sourceMap } = resolveSourceCode(scriptPath, sourceRoot); + + // Try parsing as script first (CJS), fall back to module (ESM). + // Track which succeeded — CJS modules have a V8 wrapper that shifts byte offsets. + let ast; + let isCJS = false; + try { + ast = acorn.parse(code, { + ecmaVersion: "latest", + sourceType: "script", + locations: true, + }); + isCJS = true; + } catch { + ast = acorn.parse(code, { + ecmaVersion: "latest", + sourceType: "module", + locations: true, + }); + } + + // Strip sourceMappingURL from code passed to convert() — we already loaded + // and fixed the source map ourselves. Without this, ast-v8-to-istanbul would + // read the on-disk .map file (with broken sourceRoot) via getInlineSourceMap. + const codeForConvert = sourceMap + ? code.replace(/\/\/[#@]\s*sourceMappingURL=.+$/m, "") + : code; + + // Node.js wraps CJS modules with a function header: + // (function(exports, require, module, __filename, __dirname) { ... }) + // V8 coverage byte offsets include this wrapper, so we pass wrapperLength + // to align AST node positions with V8 ranges. + // Get the actual wrapper length from Node.js rather than hardcoding. + const cjsWrapperLength = isCJS + ? require("module").wrapper[0].length + : 0; + const istanbulData = await convert({ + code: codeForConvert, + ast, + coverage: { functions: script.functions, url: script.url }, + ...(sourceMap ? { sourceMap } : {}), + ...(cjsWrapperLength ? { wrapperLength: cjsWrapperLength } : {}), + }); + + // When source maps are present, istanbul remaps to original file paths. + // Use the first key that points to a file under sourceRoot. + const fileKey = Object.keys(istanbulData).find( + (k) => k.startsWith(sourceRoot) || !path.isAbsolute(k), + ) || Object.keys(istanbulData)[0]; + if (!fileKey) continue; + const fileCov = istanbulData[fileKey]; + + // Extract line coverage from Istanbul statement map + const lines: Record = {}; + for (const [stmtId, count] of Object.entries( + fileCov.s as Record, + )) { + const stmtMap = fileCov.statementMap[stmtId]; + if (stmtMap) { + const line = String(stmtMap.start.line); + lines[line] = Math.max(lines[line] ?? 0, count); + } + } + + // Extract branch coverage from Istanbul branch map + let totalBranches = 0; + let coveredBranches = 0; + const branches: Record = {}; + + for (const [branchId, counts] of Object.entries( + fileCov.b as Record, + )) { + const branchMap = fileCov.branchMap[branchId]; + if (!branchMap) continue; + + const line = branchMap.loc?.start?.line ?? + branchMap.locations?.[0]?.start?.line; + if (line == null) continue; + const branchLine = String(line); + + if (!branches[branchLine]) { + branches[branchLine] = { total: 0, covered: 0 }; + } + + for (const count of counts) { + totalBranches++; + branches[branchLine].total++; + if (count > 0) { + coveredBranches++; + branches[branchLine].covered++; + } + } + } + + // Filter based on mode + if (!includeAll) { + for (const key of Object.keys(lines)) { + if (lines[key] === 0) { + delete lines[key]; + } + } + } + + if (Object.keys(lines).length > 0 || includeAll) { + // Use the original source path (from source map) if available, + // otherwise use the compiled file path + // Use original .ts path when source maps remap, otherwise compiled path + const coveragePath = sourceMap + ? resolveSourcePath(fileKey, resolvedPath) + : scriptPath; + coverage[coveragePath] = { + lines, + totalBranches, + coveredBranches, + branches, + }; + } + } catch { + continue; + } + } + + return coverage; +} + +/** + * Quick-scan a V8 coverage JSON to check if it has user scripts worth processing. + * Parses the JSON and checks script URLs against the sourceRoot — much cheaper + * than running ast-v8-to-istanbul on every script. + * + * Returns the parsed data if it has user scripts, null otherwise. + */ +function quickScanCoverageFile( + filePath: string, + sourceRoot: string, +): V8CoverageData | null { + try { + const data: V8CoverageData = JSON.parse(fs.readFileSync(filePath, "utf-8")); + const hasUserScripts = data.result.some( + (script) => filterScriptUrl(script.url, sourceRoot) !== null, + ); + return hasUserScripts ? data : null; + } catch { + return null; + } +} + +/** + * Take a V8 coverage snapshot: trigger v8.takeCoverage(), process with + * ast-v8-to-istanbul, and clean up. + * + * NODE_V8_COVERAGE is inherited by all child Node processes (npm, tsc, etc.), + * so the coverage directory may contain files from multiple PIDs. We quick-scan + * each file to find ones with user scripts and only run the expensive + * ast-v8-to-istanbul processing on those. + */ +export async function takeAndProcessSnapshot( + coverageDir: string, + sourceRoot: string, + includeAll: boolean, +): Promise { + // Lazy-loaded: v8 module is only needed for coverage snapshots. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const v8 = require("v8"); + v8.takeCoverage(); + + const files = fs + .readdirSync(coverageDir) + .filter((f: string) => f.startsWith("coverage-") && f.endsWith(".json")) + .sort(); + + const coverage: CoverageResult = {}; + + for (const f of files) { + const fp = path.join(coverageDir, f); + + // Quick-scan: skip files from non-server processes (npm, tsc, etc.) + const data = quickScanCoverageFile(fp, sourceRoot); + if (!data) { + try { fs.unlinkSync(fp); } catch { /* ignore cleanup errors */ } + continue; + } + + // Process the file with ast-v8-to-istanbul (expensive) — pass pre-parsed data to avoid double JSON.parse + const fileCoverage = await processV8CoverageFile(fp, sourceRoot, includeAll, data); + + // Merge into result (handles rare case of same file in multiple V8 outputs) + for (const [filePath, fileData] of Object.entries(fileCoverage)) { + if (coverage[filePath]) { + // Merge line counts (max) + for (const [line, count] of Object.entries(fileData.lines)) { + coverage[filePath].lines[line] = Math.max(coverage[filePath].lines[line] ?? 0, count); + } + // Merge branch counts + for (const [line, branchInfo] of Object.entries(fileData.branches || {})) { + 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 }; + } + } + // Recompute file-level branch totals + let totalB = 0, covB = 0; + for (const b of Object.values(coverage[filePath].branches)) { + totalB += b.total; + covB += b.covered; + } + coverage[filePath].totalBranches = totalB; + coverage[filePath].coveredBranches = covB; + } else { + coverage[filePath] = fileData; + } + } + + try { fs.unlinkSync(fp); } catch { /* ignore cleanup errors */ } + } + + return coverage; +} +