From 79f7c34468f487a468fab2880afe2bbec1a9995f Mon Sep 17 00:00:00 2001 From: Steve Zhang Date: Sat, 4 Jul 2026 09:52:04 -0400 Subject: [PATCH] feat: remove monocart-coverage-reports dependency (#61) - Port CDP client directly into src/collector/cdp-client.ts using Node.js 22 native WebSocket global - Remove monocart-coverage-reports from dependencies - Bump engines.node to >=22.0.0 - Disable worker threads on Windows by default to fix STATUS_OBJECT_NAME_NOT_FOUND crash from Rollup native addon - Fix ESLint errors in merge.ts, in-process.ts, sourcemap-loader.ts - Add comprehensive unit tests for cdp-client.ts (34 tests) - Bump version to 1.5.0 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 + package-lock.json | 99 +---- package.json | 5 +- src/__tests__/worker-pool.test.ts | 18 +- src/cli/commands/merge.ts | 2 +- src/collector/__tests__/cdp-client.test.ts | 450 +++++++++++++++++++++ src/collector/__tests__/dev-server.test.ts | 24 +- src/collector/__tests__/test-utils.ts | 8 +- src/collector/__tests__/v8-server.test.ts | 28 +- src/collector/cdp-client.ts | 315 +++++++++++++++ src/collector/cdp-utils.ts | 17 +- src/collector/dev-server.ts | 23 +- src/collector/in-process.ts | 2 +- src/collector/v8-server.ts | 5 +- src/core/sourcemap-loader.ts | 2 +- src/worker/pool.ts | 10 + 16 files changed, 852 insertions(+), 166 deletions(-) create mode 100644 src/collector/__tests__/cdp-client.test.ts create mode 100644 src/collector/cdp-client.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ae55a..887f2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [1.5.0] - 2026-07-04 + +### Changed + +- **Remove `monocart-coverage-reports` dependency** — The CDP client is now implemented directly in `src/collector/cdp-client.ts` using Node.js 22's built-in `WebSocket` global, eliminating a large transitive dependency. The public API is unchanged. + +- **Bump `engines.node` to `>=22.0.0`** — Required for the native `WebSocket` global used by the new CDP client. + +- **Disable worker threads on Windows by default** — Worker threads are now automatically set to 0 on Windows (`process.platform === 'win32'`) to avoid a `STATUS_OBJECT_NAME_NOT_FOUND` (0xC0000034) crash caused by Rollup's native Rust binary (`@rollup/rollup-win32-x64-msvc`) failing to initialise in worker thread context. Processing falls back to single-threaded mode via the existing `runTaskDirect` path. Override with `NEXTCOV_WORKERS=` or the `workers` fixture option if needed. + ## [1.4.3] - 2026-06-30 ### Added diff --git a/package-lock.json b/package-lock.json index f4f98a3..02f84eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.0", - "monocart-coverage-reports": "^2.12.12", "vite": "^8.1.2" }, "bin": { @@ -48,7 +47,7 @@ "vitest": "^4.1.9" }, "engines": { - "node": ">=20.19.0" + "node": ">=22.0.0" }, "peerDependencies": { "@playwright/test": "^1.40.0" @@ -2357,6 +2356,7 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2375,30 +2375,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-loose": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", - "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2656,15 +2632,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -2682,12 +2649,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/console-grid": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz", - "integrity": "sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==", - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2760,12 +2721,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/eight-colors": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz", - "integrity": "sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==", - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3841,12 +3796,6 @@ "node": "20 || >=22" } }, - "node_modules/lz-utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.1.tgz", - "integrity": "sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==", - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3958,50 +3907,6 @@ "ufo": "^1.6.1" } }, - "node_modules/monocart-coverage-reports": { - "version": "2.12.12", - "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.12.12.tgz", - "integrity": "sha512-d9FdUr2dn58Crweon0IE0zVi8r/i4vLvfvb2G7eL5vL5LfrxCB2X6F6qzuiwV6RioA4zbAI//7CYi6LjCvN3zA==", - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "acorn-loose": "^8.5.2", - "acorn-walk": "^8.3.5", - "commander": "^14.0.3", - "console-grid": "^2.2.4", - "eight-colors": "^1.3.3", - "foreground-child": "^4.0.3", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "lz-utils": "^2.1.1", - "monocart-locator": "^1.0.3" - }, - "bin": { - "mcr": "lib/cli.js" - } - }, - "node_modules/monocart-coverage-reports/node_modules/foreground-child": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-4.0.3.tgz", - "integrity": "sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/monocart-locator": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/monocart-locator/-/monocart-locator-1.0.3.tgz", - "integrity": "sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 97e2802..73bc844 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextcov", - "version": "1.4.3", + "version": "1.5.0", "description": "Collect test coverage for Playwright E2E tests in Next.js applications", "author": "Steve Zhang", "license": "MIT", @@ -49,7 +49,7 @@ "vitest" ], "engines": { - "node": ">=20.19.0" + "node": ">=22.0.0" }, "peerDependencies": { "@playwright/test": "^1.40.0" @@ -68,7 +68,6 @@ "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.0", - "monocart-coverage-reports": "^2.12.12", "vite": "^8.1.2" }, "devDependencies": { diff --git a/src/__tests__/worker-pool.test.ts b/src/__tests__/worker-pool.test.ts index 613b798..8ddc438 100644 --- a/src/__tests__/worker-pool.test.ts +++ b/src/__tests__/worker-pool.test.ts @@ -229,9 +229,13 @@ describe('worker-pool', () => { await terminateWorkerPool() // Reset global pool const pool = getWorkerPool() - // Should fall back to auto-detection (min 2, max 8) - expect(pool.poolSize).toBeGreaterThanOrEqual(2) - expect(pool.poolSize).toBeLessThanOrEqual(8) + // Should fall back to auto-detection (min 2, max 8 on non-Windows; 0 on Windows) + if (process.platform === 'win32') { + expect(pool.poolSize).toBe(0) + } else { + expect(pool.poolSize).toBeGreaterThanOrEqual(2) + expect(pool.poolSize).toBeLessThanOrEqual(8) + } }) it('should ignore negative NEXTCOV_WORKERS values', async () => { @@ -239,8 +243,12 @@ describe('worker-pool', () => { await terminateWorkerPool() // Reset global pool const pool = getWorkerPool() - // Should fall back to auto-detection - expect(pool.poolSize).toBeGreaterThanOrEqual(2) + // Should fall back to auto-detection (0 on Windows) + if (process.platform === 'win32') { + expect(pool.poolSize).toBe(0) + } else { + expect(pool.poolSize).toBeGreaterThanOrEqual(2) + } }) }) }) diff --git a/src/cli/commands/merge.ts b/src/cli/commands/merge.ts index 58419fd..a810406 100644 --- a/src/cli/commands/merge.ts +++ b/src/cli/commands/merge.ts @@ -119,7 +119,7 @@ export function stripCoverageDirectives(coverageJson: Record = [] + private methodResponses = new Map() + + constructor(url: string) { + super() + this.url = url + capturedWs = this + } + + /** Register a result for a specific CDP method */ + respondTo(method: string, result: unknown): this { + this.methodResponses.set(method, result) + return this + } + + send(data: string) { + const msg = JSON.parse(data) as { id: number; method: string; params: unknown } + this.sent.push(msg) + // Respond synchronously — WSSession listener is already registered by this point + const result = this.methodResponses.get(msg.method) ?? null + this.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify({ id: msg.id, result }), + })) + } + + close() {} + + triggerOpen() { + this.dispatchEvent(new Event('open')) + } + + triggerError() { + this.dispatchEvent(new Event('error')) + } + + /** Simulate a CDP server-push event (no id) */ + emitEvent(method: string, params: unknown) { + this.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify({ method, params }), + })) + } + + getSent(method: string) { + return this.sent.filter((m) => m.method === method) + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Create a connected CDPClientInstance using the direct-url path (no fetch). + * Synchronously triggers WS open so the returned client is ready to use. + */ +async function connect(): Promise<{ client: CDPClientInstance; ws: MockWebSocket }> { + const promise = CDPClient({ url: 'ws://mock' }) + const ws = capturedWs! + ws.triggerOpen() + const client = await promise + if (!client) throw new Error('Client was undefined') + return { client, ws } +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + capturedWs = null + vi.stubGlobal('WebSocket', MockWebSocket) + vi.stubGlobal('fetch', vi.fn()) +}) + +// ─── CDPClient factory ──────────────────────────────────────────────────────── + +describe('CDPClient', () => { + describe('fetch-based target discovery', () => { + it('returns undefined when fetch throws', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')) + const client = await CDPClient({ port: 9222 }) + expect(client).toBeUndefined() + }) + + it('returns undefined when targets is not an array', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve({ error: 'not an array' }), + } as unknown as Response) + const client = await CDPClient({ port: 9222 }) + expect(client).toBeUndefined() + }) + + it('returns undefined when targets array is empty', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve([]), + } as unknown as Response) + const client = await CDPClient({ port: 9222 }) + expect(client).toBeUndefined() + }) + + it('returns undefined when no target has a webSocketDebuggerUrl', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve([{ type: 'page' }, { type: 'node' }]), + } as unknown as Response) + const client = await CDPClient({ port: 9222 }) + expect(client).toBeUndefined() + }) + + it('uses correct host and port in the fetch URL', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve([]), + } as unknown as Response) + await CDPClient({ host: 'myhost', port: 1234 }) + expect(fetch).toHaveBeenCalledWith('http://myhost:1234/json/list') + }) + + it('prefers page target over other types', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve([ + { webSocketDebuggerUrl: 'ws://node-target', type: 'node' }, + { webSocketDebuggerUrl: 'ws://page-target', type: 'page' }, + ]), + } as unknown as Response) + const promise = CDPClient({ port: 9222 }) + await Promise.resolve() // tick: fetch resolves + await Promise.resolve() // tick: json() resolves + capturedWs?.triggerOpen() + await promise + expect(capturedWs?.url).toBe('ws://page-target') + }) + + it('falls back to first target with webSocketDebuggerUrl when no page type', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: () => Promise.resolve([ + { webSocketDebuggerUrl: 'ws://node-target', type: 'node' }, + ]), + } as unknown as Response) + const promise = CDPClient({ port: 9222 }) + await Promise.resolve() + await Promise.resolve() + capturedWs?.triggerOpen() + await promise + expect(capturedWs?.url).toBe('ws://node-target') + }) + }) + + describe('direct URL option', () => { + it('skips fetch and uses provided URL directly', async () => { + const promise = CDPClient({ url: 'ws://direct' }) + capturedWs!.triggerOpen() + await promise + expect(fetch).not.toHaveBeenCalled() + expect(capturedWs?.url).toBe('ws://direct') + }) + + it('returns a CDPClientInstance on successful connection', async () => { + const { client } = await connect() + expect(client).toBeDefined() + expect(typeof client.startJSCoverage).toBe('function') + expect(typeof client.stopJSCoverage).toBe('function') + expect(typeof client.close).toBe('function') + }) + + it('returns undefined when WebSocket emits error', async () => { + const promise = CDPClient({ url: 'ws://mock' }) + capturedWs!.triggerError() + const client = await promise + expect(client).toBeUndefined() + }) + + it('returns undefined when timeout elapses before open', async () => { + vi.useFakeTimers() + const promise = CDPClient({ url: 'ws://mock', timeout: 100 }) + vi.advanceTimersByTime(200) + const client = await promise + expect(client).toBeUndefined() + vi.useRealTimers() + }) + }) +}) + +// ─── CoverageClient.startJSCoverage ─────────────────────────────────────────── + +describe('CoverageClient.startJSCoverage', () => { + it('sends correct sequence of CDP commands', async () => { + const { client, ws } = await connect() + await client.startJSCoverage() + + const methods = ws.sent.map((m) => m.method) + expect(methods).toContain('Debugger.enable') + expect(methods).toContain('Debugger.setSkipAllPauses') + expect(methods).toContain('Profiler.enable') + expect(methods).toContain('Profiler.startPreciseCoverage') + }) + + it('sends startPreciseCoverage with callCount and detailed flags', async () => { + const { client, ws } = await connect() + await client.startJSCoverage() + + const cmd = ws.getSent('Profiler.startPreciseCoverage')[0] + expect(cmd.params).toMatchObject({ callCount: true, detailed: true }) + }) + + it('is idempotent — second call is a no-op', async () => { + const { client, ws } = await connect() + await client.startJSCoverage() + const countAfterFirst = ws.sent.length + await client.startJSCoverage() + expect(ws.sent.length).toBe(countAfterFirst) + }) +}) + +// ─── CoverageClient.stopJSCoverage ──────────────────────────────────────────── + +describe('CoverageClient.stopJSCoverage', () => { + it('returns empty array when coverage was not started', async () => { + const { client } = await connect() + const entries = await client.stopJSCoverage() + expect(entries).toEqual([]) + }) + + it('returns coverage entries with sources from scriptSources map', async () => { + const { client, ws } = await connect() + + ws.respondTo('Profiler.takePreciseCoverage', { + result: [ + { + scriptId: 'abc', + url: 'file:///project/src/foo.ts', + functions: [{ functionName: 'foo', ranges: [{ startOffset: 0, endOffset: 10, count: 1 }], isBlockCoverage: true }], + }, + ], + }) + ws.respondTo('Debugger.getScriptSource', { scriptSource: 'function foo() {}' }) + + await client.startJSCoverage() + // Simulate the server notifying us about a parsed script + ws.emitEvent('Debugger.scriptParsed', { scriptId: 'abc' }) + // Allow the getScriptSource request/response to complete + await Promise.resolve() + await Promise.resolve() + + const entries = await client.stopJSCoverage() + + expect(entries).toHaveLength(1) + expect(entries[0].scriptId).toBe('abc') + expect(entries[0].url).toBe('file:///project/src/foo.ts') + expect(entries[0].source).toBe('function foo() {}') + expect(entries[0].functions).toHaveLength(1) + }) + + it('falls back to empty string when getScriptSource fails', async () => { + const { client, ws } = await connect() + + ws.respondTo('Profiler.takePreciseCoverage', { + result: [{ scriptId: 'xyz', url: 'file:///foo.js', functions: [] }], + }) + // Override send to throw for getScriptSource + const originalSend = ws.send.bind(ws) + ws.send = (data: string) => { + const msg = JSON.parse(data) as { method: string; id: number } + if (msg.method === 'Debugger.getScriptSource') { + ws.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify({ id: msg.id, error: { message: 'Script not found' } }), + })) + return + } + originalSend(data) + } + + await client.startJSCoverage() + ws.emitEvent('Debugger.scriptParsed', { scriptId: 'xyz' }) + await Promise.resolve() + await Promise.resolve() + + const entries = await client.stopJSCoverage() + expect(entries[0].source).toBe('') + }) + + it('uses empty string for entries with no matching scriptSource', async () => { + const { client, ws } = await connect() + + ws.respondTo('Profiler.takePreciseCoverage', { + result: [{ scriptId: 'unknown', url: 'file:///bar.js', functions: [] }], + }) + + await client.startJSCoverage() + // No scriptParsed event fired for 'unknown', so no source stored + const entries = await client.stopJSCoverage() + expect(entries[0].source).toBe('') + }) + + it('sends Profiler.stopPreciseCoverage and Profiler.disable', async () => { + const { client, ws } = await connect() + ws.respondTo('Profiler.takePreciseCoverage', { result: [] }) + + await client.startJSCoverage() + await client.stopJSCoverage() + + expect(ws.getSent('Profiler.stopPreciseCoverage')).toHaveLength(1) + expect(ws.getSent('Profiler.disable')).toHaveLength(1) + }) + + it('can be started and stopped again after a first cycle', async () => { + const { client, ws } = await connect() + ws.respondTo('Profiler.takePreciseCoverage', { result: [] }) + + await client.startJSCoverage() + await client.stopJSCoverage() + + // Second cycle + ws.sent = [] + await client.startJSCoverage() + await client.stopJSCoverage() + + expect(ws.getSent('Profiler.startPreciseCoverage')).toHaveLength(1) + expect(ws.getSent('Profiler.stopPreciseCoverage')).toHaveLength(1) + }) +}) + +// ─── CoverageClient.close ───────────────────────────────────────────────────── + +describe('CoverageClient.close', () => { + it('calls ws.close() via session detach', async () => { + const { client, ws } = await connect() + const closeSpy = vi.spyOn(ws, 'close') + await client.close() + expect(closeSpy).toHaveBeenCalled() + }) + + it('is safe to call multiple times', async () => { + const { client } = await connect() + await client.close() + await expect(client.close()).resolves.toBeUndefined() + }) + + it('makes stopJSCoverage return [] after close', async () => { + const { client } = await connect() + await client.close() + const entries = await client.stopJSCoverage() + expect(entries).toEqual([]) + }) +}) + +// ─── CoverageClient.writeCoverage ───────────────────────────────────────────── + +describe('CoverageClient.writeCoverage', () => { + it('sends Runtime.enable and Runtime.evaluate', async () => { + const { client, ws } = await connect() + ws.respondTo('Runtime.evaluate', { result: { value: '/tmp/v8' } }) + + await client.writeCoverage() + + expect(ws.getSent('Runtime.enable')).toHaveLength(1) + expect(ws.getSent('Runtime.evaluate')).toHaveLength(1) + expect(ws.getSent('Runtime.disable')).toHaveLength(1) + }) + + it('returns the coverage directory from the evaluate result', async () => { + const { client, ws } = await connect() + ws.respondTo('Runtime.evaluate', { result: { value: '/tmp/v8-cov' } }) + + const dir = await client.writeCoverage() + expect(dir).toBe('/tmp/v8-cov') + }) + + it('returns undefined after close', async () => { + const { client } = await connect() + await client.close() + const dir = await client.writeCoverage() + expect(dir).toBeUndefined() + }) +}) + +// ─── CoverageClient.getIstanbulCoverage ─────────────────────────────────────── + +describe('CoverageClient.getIstanbulCoverage', () => { + it('sends Runtime.evaluate and returns the coverage object', async () => { + const { client, ws } = await connect() + const mockCoverage = { 'src/foo.ts': { s: { 0: 1 } } } + ws.respondTo('Runtime.evaluate', { result: { value: mockCoverage } }) + + const coverage = await client.getIstanbulCoverage() + expect(coverage).toEqual(mockCoverage) + }) + + it('uses the default __coverage__ key', async () => { + const { client, ws } = await connect() + ws.respondTo('Runtime.evaluate', { result: { value: {} } }) + + await client.getIstanbulCoverage() + + const cmd = ws.getSent('Runtime.evaluate')[0] + expect(JSON.stringify(cmd.params)).toContain('__coverage__') + }) + + it('uses a custom coverage key when provided', async () => { + const { client, ws } = await connect() + ws.respondTo('Runtime.evaluate', { result: { value: {} } }) + + await client.getIstanbulCoverage('__myCoverage__') + + const cmd = ws.getSent('Runtime.evaluate')[0] + expect(JSON.stringify(cmd.params)).toContain('__myCoverage__') + }) + + it('returns undefined after close', async () => { + const { client } = await connect() + await client.close() + const coverage = await client.getIstanbulCoverage() + expect(coverage).toBeUndefined() + }) +}) + +// ─── CoverageClient.startCoverage / stopCoverage ────────────────────────────── + +describe('CoverageClient.startCoverage / stopCoverage', () => { + it('startCoverage delegates to startJSCoverage', async () => { + const { client, ws } = await connect() + await client.startCoverage() + expect(ws.getSent('Profiler.startPreciseCoverage')).toHaveLength(1) + }) + + it('stopCoverage delegates to stopJSCoverage', async () => { + const { client, ws } = await connect() + ws.respondTo('Profiler.takePreciseCoverage', { result: [] }) + await client.startCoverage() + const entries = await client.stopCoverage() + expect(Array.isArray(entries)).toBe(true) + }) +}) + +// ─── CoverageClient.startCSSCoverage / stopCSSCoverage ─────────────────────── + +describe('CoverageClient.startCSSCoverage / stopCSSCoverage', () => { + it('startCSSCoverage is a no-op', async () => { + const { client } = await connect() + await expect(client.startCSSCoverage()).resolves.toBeUndefined() + }) + + it('stopCSSCoverage returns an empty array', async () => { + const { client } = await connect() + await expect(client.stopCSSCoverage()).resolves.toEqual([]) + }) +}) diff --git a/src/collector/__tests__/dev-server.test.ts b/src/collector/__tests__/dev-server.test.ts index 561b27d..6fbfe88 100644 --- a/src/collector/__tests__/dev-server.test.ts +++ b/src/collector/__tests__/dev-server.test.ts @@ -14,8 +14,8 @@ vi.mock('@/utils/logger.js', () => ({ safeClose: vi.fn(), })) -// Mock monocart-coverage-reports CDPClient -vi.mock('monocart-coverage-reports', () => ({ +// Mock CDPClient +vi.mock('../cdp-client.js', () => ({ CDPClient: vi.fn(), })) @@ -64,7 +64,7 @@ describe('DevModeServerCollector', () => { describe('connect', () => { it('should return false when CDPClient returns null', async () => { const { log } = await import('@/utils/logger.js') - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') // @ts-expect-error - testing null return value which can happen at runtime vi.mocked(CDPClient).mockResolvedValue(null) @@ -76,7 +76,7 @@ describe('DevModeServerCollector', () => { it('should return false when CDPClient throws', async () => { const { log } = await import('@/utils/logger.js') - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockRejectedValue(new Error('Connection refused')) const result = await collector.connect() @@ -91,7 +91,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockResolvedValue([]), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) const result = await collector.connect() @@ -118,7 +118,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() @@ -165,7 +165,7 @@ describe('DevModeServerCollector', () => { ]), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() @@ -198,7 +198,7 @@ describe('DevModeServerCollector', () => { ]), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() @@ -217,7 +217,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockRejectedValue(new Error('Collection failed')), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() @@ -233,7 +233,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockRejectedValue(new Error('Collection failed')), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) const { safeClose } = await import('@/utils/logger.js') @@ -267,7 +267,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockResolvedValue([]), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() @@ -291,7 +291,7 @@ describe('DevModeServerCollector', () => { stopJSCoverage: vi.fn().mockResolvedValue([]), close: vi.fn().mockResolvedValue(undefined), } - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient as any) await collector.connect() diff --git a/src/collector/__tests__/test-utils.ts b/src/collector/__tests__/test-utils.ts index fb374ac..728e78b 100644 --- a/src/collector/__tests__/test-utils.ts +++ b/src/collector/__tests__/test-utils.ts @@ -1,10 +1,10 @@ import { vi, type Mock } from 'vitest' -import type { CoverageClient } from 'monocart-coverage-reports' +import type { CDPClientInstance } from '../cdp-client.js' -/** Create a mock CoverageClient with all required methods */ +/** Create a mock CDPClientInstance with all required methods */ export function createMockCoverageClient( - overrides?: Partial> -): CoverageClient { + overrides?: Partial> +): CDPClientInstance { return { startJSCoverage: vi.fn().mockResolvedValue(undefined), stopJSCoverage: vi.fn().mockResolvedValue([]), diff --git a/src/collector/__tests__/v8-server.test.ts b/src/collector/__tests__/v8-server.test.ts index ac555f5..e63dad6 100644 --- a/src/collector/__tests__/v8-server.test.ts +++ b/src/collector/__tests__/v8-server.test.ts @@ -20,8 +20,8 @@ vi.mock('@/utils/logger.js', () => ({ safeClose: vi.fn(), })) -// Mock monocart-coverage-reports CDPClient -vi.mock('monocart-coverage-reports', () => ({ +// Mock CDPClient +vi.mock('../cdp-client.js', () => ({ CDPClient: vi.fn(), })) @@ -121,7 +121,7 @@ describe('V8ServerCoverageCollector', () => { describe('connect', () => { it('should return false when CDP connection fails', async () => { const { log } = await import('@/utils/logger.js') - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockRejectedValue(new Error('Connection failed')) const result = await collector.connect() @@ -133,7 +133,7 @@ describe('V8ServerCoverageCollector', () => { it('should return true when CDP connection succeeds', async () => { const { log } = await import('@/utils/logger.js') const mockClient = createMockCoverageClient() - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const result = await collector.connect() @@ -144,7 +144,7 @@ describe('V8ServerCoverageCollector', () => { it('should connect to the correct port', async () => { const mockClient = createMockCoverageClient() - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const customCollector = new V8ServerCoverageCollector({ cdpPort: 8888 }) @@ -168,7 +168,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue('/some/coverage/dir'), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) await collector.connect() @@ -182,7 +182,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue(''), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) await collector.connect() @@ -196,7 +196,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockRejectedValue(new Error('Write failed')), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) await collector.connect() @@ -295,7 +295,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue('/nonexistent/dir'), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const { existsSync } = await import('node:fs') @@ -311,7 +311,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue(testCacheDir), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const { existsSync, readdirSync } = await import('node:fs') @@ -328,7 +328,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue(testCacheDir), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const { existsSync, readdirSync, readFileSync } = await import('node:fs') @@ -353,7 +353,7 @@ describe('V8ServerCoverageCollector', () => { const mockClient = createMockCoverageClient({ writeCoverage: vi.fn().mockResolvedValue(testCacheDir), }) - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const { existsSync, readdirSync } = await import('node:fs') @@ -455,7 +455,7 @@ describe('startV8ServerCoverage and stopV8ServerCoverage', () => { it('should start and connect via startV8ServerCoverage', async () => { const mockClient = createMockCoverageClient() - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockResolvedValue(mockClient) const result = await startV8ServerCoverage({ cdpPort: 9230 }) @@ -464,7 +464,7 @@ describe('startV8ServerCoverage and stopV8ServerCoverage', () => { }) it('should return false if connection fails', async () => { - const { CDPClient } = await import('monocart-coverage-reports') + const { CDPClient } = await import('../cdp-client.js') vi.mocked(CDPClient).mockRejectedValue(new Error('Connection failed')) const result = await startV8ServerCoverage({ cdpPort: 9230 }) diff --git a/src/collector/cdp-client.ts b/src/collector/cdp-client.ts new file mode 100644 index 0000000..631d6cf --- /dev/null +++ b/src/collector/cdp-client.ts @@ -0,0 +1,315 @@ +/** + * CDP Client + * + * Lightweight Chrome DevTools Protocol client for JS coverage collection. + * Ported from monocart-coverage-reports (MIT) to remove the large dependency. + * + * Requires Node.js >= 22 for native WebSocket support. + */ + +import { EventEmitter } from 'node:events' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface JSCoverageEntry { + scriptId: string + url: string + source: string + functions: Array<{ + functionName: string + ranges: Array<{ startOffset: number; endOffset: number; count: number }> + isBlockCoverage: boolean + }> +} + +export interface CDPClientInstance { + startJSCoverage(): Promise + stopJSCoverage(): Promise + writeCoverage(): Promise + getIstanbulCoverage(coverageKey?: string): Promise + startCSSCoverage(): Promise + stopCSSCoverage(): Promise + startCoverage(): Promise + stopCoverage(): Promise + close(): Promise +} + +// ─── WSSession ─────────────────────────────────────────────────────────────── + +class WSSession extends EventEmitter { + private ws: WebSocket | null + private requestId = 1 + private requestCache = new Map void; reject: (err: Error) => void }>() + + constructor(ws: WebSocket) { + super() + this.ws = ws + + ws.addEventListener('message', (event) => { + const message = JSON.parse(event.data as string) as { + id?: number + method?: string + params?: unknown + result?: unknown + sessionId?: string + } + + if (message.id !== undefined) { + const request = this.requestCache.get(message.id) + this.requestCache.delete(message.id) + if (request) { + request.resolve(message.result) + } + return + } + + if (message.method) { + this.emit(message.method, message.params, message.sessionId) + } + }) + } + + send(method: string, params?: Record): Promise { + return new Promise((resolve, reject) => { + if (!this.ws) { + reject(new Error('Invalid websocket')) + return + } + const id = this.requestId++ + const message = JSON.stringify({ id, method, params: params ?? {} }) + this.requestCache.set(id, { resolve, reject }) + try { + this.ws.send(message) + } catch (err) { + this.requestCache.delete(id) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) + } + + detach(): void { + if (this.ws) { + this.ws.close() + this.ws = null + } + } +} + +// ─── CoverageClient ────────────────────────────────────────────────────────── + +class CoverageClient implements CDPClientInstance { + private session: WSSession | null + private enabledJS = false + private scriptSources = new Map() + private jsEventHandlers: { + 'Debugger.scriptParsed': (params: unknown) => void + 'Debugger.paused': () => void + } | null = null + + constructor(session: WSSession) { + this.session = session + } + + async startJSCoverage(): Promise { + if (!this.session || this.enabledJS) return + + this.enabledJS = true + this.scriptSources = new Map() + + this.jsEventHandlers = { + 'Debugger.scriptParsed': (params) => { + const { scriptId } = params as { scriptId: string } + this.session!.send('Debugger.getScriptSource', { scriptId }).then((res) => { + const result = res as { scriptSource?: string } | null + this.scriptSources.set(scriptId, result?.scriptSource ?? '') + }).catch(() => { + this.scriptSources.set(scriptId, '') + }) + }, + 'Debugger.paused': () => { + this.session!.send('Debugger.resume').catch(() => {}) + }, + } + + for (const [event, handler] of Object.entries(this.jsEventHandlers)) { + this.session.on(event, handler) + } + + await this.session.send('Debugger.enable') + await this.session.send('Debugger.setSkipAllPauses', { skip: true }) + await this.session.send('Profiler.enable') + await this.session.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true }) + } + + async stopJSCoverage(): Promise { + if (!this.session || !this.enabledJS) return [] + + const profileResponse = await this.session.send('Profiler.takePreciseCoverage') as { + result?: Array<{ + scriptId: string + url: string + functions: JSCoverageEntry['functions'] + }> + } | null + + await this.session.send('Profiler.stopPreciseCoverage') + await this.session.send('Profiler.disable') + + if (this.jsEventHandlers) { + for (const [event, handler] of Object.entries(this.jsEventHandlers)) { + this.session.off(event, handler) + } + this.jsEventHandlers = null + } + + const jsCoverage: JSCoverageEntry[] = [] + + if (profileResponse?.result) { + for (const entry of profileResponse.result) { + jsCoverage.push({ + scriptId: entry.scriptId, + url: entry.url ?? '', + source: this.scriptSources.get(entry.scriptId) ?? '', + functions: entry.functions, + }) + } + } + + this.scriptSources.clear() + this.enabledJS = false + + return jsCoverage + } + + async startCSSCoverage(): Promise { + // Not used by nextcov — included to satisfy CDPClientInstance interface + } + + async stopCSSCoverage(): Promise { + return [] + } + + async startCoverage(): Promise { + await this.startJSCoverage() + } + + async stopCoverage(): Promise { + return this.stopJSCoverage() + } + + async writeCoverage(): Promise { + if (!this.session) return undefined + + await this.session.send('Runtime.enable') + + const res = await this.session.send('Runtime.evaluate', { + expression: `new Promise((resolve) => { + require("v8").takeCoverage(); + resolve(process.env.NODE_V8_COVERAGE); + })`, + includeCommandLineAPI: true, + returnByValue: true, + awaitPromise: true, + }) as { result?: { value?: string } } | null + + await this.session.send('Runtime.disable') + + return res?.result?.value + } + + async getIstanbulCoverage(coverageKey = '__coverage__'): Promise { + if (!this.session) return undefined + + await this.session.send('Runtime.enable') + + const res = await this.session.send('Runtime.evaluate', { + expression: `new Promise((resolve) => { + const globalTarget = typeof window !== 'undefined' ? window : global; + resolve(globalTarget['${coverageKey}']); + })`, + includeCommandLineAPI: true, + returnByValue: true, + awaitPromise: true, + }) as { result?: { value?: unknown } } | null + + await this.session.send('Runtime.disable') + + return res?.result?.value + } + + async close(): Promise { + if (!this.session) return + this.session.detach() + this.session = null + } +} + +// ─── CDPClient factory ─────────────────────────────────────────────────────── + +export interface CDPClientOptions { + port?: number + host?: string + url?: string + timeout?: number +} + +/** + * Connect to the Chrome DevTools Protocol and return a CoverageClient. + * Returns undefined if the connection fails (matching monocart's behavior). + */ +export async function CDPClient(options: CDPClientOptions): Promise { + const host = options.host ?? 'localhost' + const port = options.port ?? 9222 + const timeout = options.timeout ?? 10_000 + + let wsUrl: string + + if (options.url) { + wsUrl = options.url + } else { + // Fetch the debugger URL from /json/list + let targets: Array<{ webSocketDebuggerUrl?: string; type?: string }> + try { + const res = await fetch(`http://${host}:${port}/json/list`) + targets = await res.json() as typeof targets + } catch { + return undefined + } + + if (!Array.isArray(targets) || targets.length === 0) return undefined + + const target = + targets.find((t) => t.webSocketDebuggerUrl && t.type === 'page') ?? + targets.find((t) => t.webSocketDebuggerUrl) + + if (!target?.webSocketDebuggerUrl) return undefined + wsUrl = target.webSocketDebuggerUrl + } + + // Connect via WebSocket (native, available in Node >= 22) + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + resolve(undefined) + }, timeout) + + let ws: WebSocket + try { + ws = new WebSocket(wsUrl) + } catch { + clearTimeout(timeoutId) + resolve(undefined) + return + } + + ws.addEventListener('error', () => { + clearTimeout(timeoutId) + resolve(undefined) + }) + + ws.addEventListener('open', () => { + clearTimeout(timeoutId) + const session = new WSSession(ws) + resolve(new CoverageClient(session)) + }) + }) +} diff --git a/src/collector/cdp-utils.ts b/src/collector/cdp-utils.ts index d5bbdb6..e5b211c 100644 --- a/src/collector/cdp-utils.ts +++ b/src/collector/cdp-utils.ts @@ -7,15 +7,14 @@ import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { CDPClient } from 'monocart-coverage-reports' +import { CDPClient } from './cdp-client.js' +import type { CDPClientInstance } from './cdp-client.js' +export type { CDPClientInstance } from './cdp-client.js' import { log, safeClose } from '@/utils/logger.js' /** Default timeout for CDP port availability check (ms) */ const CDP_CHECK_TIMEOUT = 2000 -/** Monocart CDPClient type */ -export type MonocartCDPClient = Awaited> - /** Base coverage entry from CDP */ export interface BaseCoverageEntry { url: string @@ -31,7 +30,7 @@ export interface BaseCoverageEntry { * Check if CDP client is connected * @returns true if connected, false otherwise (logs warning) */ -export function isClientConnected(client: unknown, mode?: string): client is NonNullable { +export function isClientConnected(client: unknown, mode?: string): client is CDPClientInstance { if (!client) { const suffix = mode ? ` (${mode})` : '' log(` ⚠️ CDP not connected${suffix}`) @@ -76,7 +75,7 @@ export function logCollectionError(error: unknown, mode?: string): void { * for CDP coverage collection. */ export async function collectCoverage( - client: NonNullable, + client: CDPClientInstance, options: { mode?: string filter: (entries: TRaw[]) => TRaw[] @@ -132,7 +131,6 @@ export function attachSourceContent(entries: T[]): /** * Check if a CDP port is available by making a quick HTTP request to /json/list. - * This avoids triggering monocart's error logging when the port is unavailable. * * @param port - CDP port to check * @param timeout - Timeout in milliseconds (default: 2000) @@ -177,11 +175,10 @@ export async function connectToCdp( mode?: string, skipAvailabilityCheck: boolean = false, timeout: number = DEFAULT_CDP_TIMEOUT -): Promise { +): Promise { const suffix = mode ? ` (${mode})` : '' // Pre-check: verify CDP port is available before calling CDPClient - // This avoids monocart's noisy [MCR] Error logging when port is unavailable if (!skipAvailabilityCheck) { const available = await isCdpPortAvailable(port) if (!available) { @@ -230,7 +227,7 @@ export async function connectAndStartCoverage( mode?: string, skipAvailabilityCheck: boolean = false, timeout: number = DEFAULT_CDP_TIMEOUT -): Promise { +): Promise { const client = await connectToCdp(port, mode, skipAvailabilityCheck, timeout) if (!client) { return null diff --git a/src/collector/dev-server.ts b/src/collector/dev-server.ts index 2d4b1cd..ffcc286 100644 --- a/src/collector/dev-server.ts +++ b/src/collector/dev-server.ts @@ -1,7 +1,7 @@ /** * Dev Mode Server Coverage Collector * - * Collects server-side coverage in dev mode using monocart-coverage-reports CDPClient. + * Collects server-side coverage in dev mode using a CDP client. * In dev mode, server scripts have inline source maps that need to be * extracted from the script source. * @@ -17,18 +17,13 @@ import { DevModeSourceMapExtractor } from '@/utils/dev-mode-extractor.js' import { DEFAULT_DEV_MODE_OPTIONS, DEFAULT_NEXTCOV_CONFIG } from '@/utils/config.js' import { log } from '@/utils/logger.js' import { - type MonocartCDPClient, + type CDPClientInstance, type BaseCoverageEntry, isClientConnected, collectCoverage, connectAndStartCoverage, } from './cdp-utils.js' - -/** Coverage entry returned by monocart stopJSCoverage */ -interface MonocartCoverageEntry extends BaseCoverageEntry { - scriptId: string - source: string -} +import type { JSCoverageEntry } from './cdp-client.js' export interface DevServerCollectorConfig { /** CDP port for the server worker process (default: 9231) */ @@ -47,7 +42,7 @@ export interface DevServerCoverageEntry extends BaseCoverageEntry { /** * Dev Mode Server Coverage Collector * - * Uses monocart-coverage-reports CDPClient to: + * Uses CDP client to: * 1. Connect to CDP and start JS coverage collection * 2. Automatically collect script sources via Debugger API * 3. Stop coverage and get results with source attached @@ -55,7 +50,7 @@ export interface DevServerCoverageEntry extends BaseCoverageEntry { */ export class DevModeServerCollector { private config: DevServerCollectorConfig - private client: MonocartCDPClient | null = null + private client: CDPClientInstance | null = null private extractor: DevModeSourceMapExtractor constructor(config?: Partial) { @@ -87,7 +82,7 @@ export class DevModeServerCollector { return [] } - return collectCoverage(this.client, { + return collectCoverage(this.client, { mode: 'dev mode', filter: (entries) => { log(` Found ${entries.length} total scripts`) @@ -103,7 +98,7 @@ export class DevModeServerCollector { /** * Transform coverage entries and extract source maps */ - private transformEntries(entries: MonocartCoverageEntry[]): DevServerCoverageEntry[] { + private transformEntries(entries: JSCoverageEntry[]): DevServerCoverageEntry[] { return entries.map((coverage) => { const extracted = this.extractor.extractFromScriptSource(coverage.url, coverage.source) @@ -142,11 +137,9 @@ export class DevModeServerCollector { } /** - * Wait for webpack scripts - now a no-op since monocart handles this - * Kept for API compatibility + * Wait for webpack scripts - no-op, kept for API compatibility */ async waitForWebpackScripts(_timeoutMs: number = 10000): Promise { - // Monocart's startJSCoverage already waits for script parsing return true } diff --git a/src/collector/in-process.ts b/src/collector/in-process.ts index 2eb3798..92aa485 100644 --- a/src/collector/in-process.ts +++ b/src/collector/in-process.ts @@ -71,7 +71,7 @@ export class InProcessV8Collector { async collect(): Promise { if (!this.session) return [] - const { result } = await this.session.post('Profiler.takePreciseCoverage') as { result: Array<{ scriptId: string; url: string; functions: any[] }> } + const { result } = await this.session.post('Profiler.takePreciseCoverage') as { result: Array<{ scriptId: string; url: string; functions: InProcessCoverageEntry['functions'] }> } const entries: InProcessCoverageEntry[] = [] for (const script of result) { diff --git a/src/collector/v8-server.ts b/src/collector/v8-server.ts index 51f18cb..d8aec00 100644 --- a/src/collector/v8-server.ts +++ b/src/collector/v8-server.ts @@ -25,7 +25,7 @@ import { containsSourceRoot } from '@/parsers/webpack.js' import { isLocalFileUrl, isNodeModulesUrl } from '@/parsers/index.js' import { log, safeClose } from '@/utils/logger.js' import { - type MonocartCDPClient, + type CDPClientInstance, type BaseCoverageEntry, connectToCdp, attachSourceContent, @@ -52,7 +52,7 @@ export interface V8ServerCollectorConfig { */ export class V8ServerCoverageCollector { private config: Required - private cdpClient: MonocartCDPClient | null = null + private cdpClient: CDPClientInstance | null = null constructor(config?: Partial) { // Get v8 coverage dir from env or config @@ -90,7 +90,6 @@ export class V8ServerCoverageCollector { try { // Use CDP to execute v8.takeCoverage() in the remote process - // This is what monocart's writeCoverage() does internally const dir = await this.cdpClient.writeCoverage() log(` ✓ Triggered v8.takeCoverage(), coverage dir: ${dir}`) return dir || this.config.v8CoverageDir diff --git a/src/core/sourcemap-loader.ts b/src/core/sourcemap-loader.ts index 67f54a2..746985b 100644 --- a/src/core/sourcemap-loader.ts +++ b/src/core/sourcemap-loader.ts @@ -79,7 +79,7 @@ export class SourceMapLoader { * Includes path traversal protection to prevent escaping project boundaries. */ urlToFilePath(url: string): string | null { - let filePath: string | null = null + let filePath: string | null // Handle file:// URLs if (url.startsWith(FILE_PROTOCOL)) { diff --git a/src/worker/pool.ts b/src/worker/pool.ts index 023ec77..97768bd 100644 --- a/src/worker/pool.ts +++ b/src/worker/pool.ts @@ -6,6 +6,8 @@ * * Set NEXTCOV_WORKERS=0 to disable worker threads and run in single-threaded mode. * This can be faster in environments with high worker thread overhead (e.g., some CI). + * On Windows, worker threads are disabled by default because Vite's parseAstAsync + * (backed by a native Rust addon) crashes with STATUS_OBJECT_NAME_NOT_FOUND in workers. */ import { Worker } from 'node:worker_threads' import { cpus } from 'node:os' @@ -31,6 +33,14 @@ function getWorkerCount(): number { } } + // Worker threads on Windows crash with STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034) + // when native addons (Rollup's Rust binary via Vite's parseAstAsync) try to access + // OS-level named objects that are only visible from the main thread. + // Fall back to single-threaded mode on Windows until the worker no longer needs Vite. + if (process.platform === 'win32') { + return 0 + } + const coreCount = cpus().length return Math.min(8, Math.max(2, Math.floor(coreCount / 2))) }