diff --git a/docs/config/coverage.md b/docs/config/coverage.md index d74568312..8f1cfb428 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -458,3 +458,14 @@ Note that setting this option does not change where coverage HTML report is gene - **CLI:** `--coverage.changed`, `--coverage.changed=` Collect coverage only for files changed since a specified commit or branch. When set to `true`, it uses staged and unstaged changes. + +## coverage.autoAttachSubprocess 5.0.0 {#coverage-autoattachsubprocess} + +- **Type:** `boolean` +- **Default:** `false` +- **Available for providers:** `'v8'` +- **CLI:** `--coverage.autoAttachSubprocess` + +Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. + +Note that this option has some performance overhead as its using [`NODE_V8_COVERAGE`](https://nodejs.org/api/cli.html#node-v8-coveragedir) internally. This triggers Node to write lots of unnecessary files on file system. diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index abdd2dad5..db20e2726 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -299,6 +299,13 @@ Apply exclusions again after coverage has been remapped to original sources. (de Directory of HTML coverage output to be served in UI mode and HTML reporter. +### coverage.autoAttachSubprocess + +- **CLI:** `--coverage.autoAttachSubprocess` +- **Config:** [coverage.autoAttachSubprocess](/config/coverage#coverage-autoattachsubprocess) + +Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. Supported only by `v8` provider. (default: false) + ### mode - **CLI:** `--mode ` diff --git a/packages/coverage-v8/src/index.ts b/packages/coverage-v8/src/index.ts index 7218cf942..b89b29a16 100644 --- a/packages/coverage-v8/src/index.ts +++ b/packages/coverage-v8/src/index.ts @@ -1,23 +1,41 @@ import type { Profiler } from 'node:inspector' import type { CoverageProviderModule } from 'vitest/node' import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider' +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { readdir, readFile, rm } from 'node:fs/promises' import inspector from 'node:inspector/promises' +import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { normalize } from 'pathe' import { provider } from 'std-env' import { loadProvider } from './load-provider' -const session = new inspector.Session() let enabled = false -const mod: CoverageProviderModule = { - async startCoverage({ isolate }) { +const mod: CoverageProviderModule & { + extendedContextCoverageDir?: string + // Use unknown to avoid bundling node:inspector + session?: unknown | null +} = { + extendedContextCoverageDir: undefined, + session: null, + + async startCoverage({ isolate, autoAttachSubprocess, reportsDirectory }) { if (isolate === false && enabled) { return } enabled = true + if (autoAttachSubprocess) { + this.extendedContextCoverageDir = resolve(reportsDirectory, 'tmp', randomUUID()) + process.env.NODE_V8_COVERAGE = this.extendedContextCoverageDir + } + + this.session ||= new inspector.Session() + const session = this.session as inspector.Session + session.connect() await session.post('Profiler.enable') await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true }) @@ -28,16 +46,50 @@ const mod: CoverageProviderModule = { return { result: [] } } + const session = this.session as inspector.Session + + if (!session) { + throw new Error('V8 provider missing inspector session.') + } + const coverage = await session.post('Profiler.takePreciseCoverage') const result: ScriptCoverageWithOffset[] = [] // Reduce amount of data sent over rpc by doing some early result filtering - for (const entry of coverage.result) { + for (const entry of coverage.result as ScriptCoverageWithOffset[]) { if (filterResult(entry)) { - result.push({ - ...entry, - startOffset: options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0, - }) + entry.startOffset = options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0 + + result.push(entry) + } + } + + if (this.extendedContextCoverageDir && existsSync(this.extendedContextCoverageDir)) { + const filenames = await readdir(this.extendedContextCoverageDir) + const contents = await Promise.all( + filenames + .filter(filename => filename.endsWith('.json')) + .map(async (filename) => { + const path = `${this.extendedContextCoverageDir}/${filename}` + + const content = await readFile(path, 'utf8') + await rm(path) + + return content + }), + ) + + for (const content of contents) { + const json: { result: ScriptCoverageWithOffset[] } = JSON.parse(content) + + for (const entry of json.result) { + if (filterResult(entry)) { + entry.startOffset = 0 + entry.isExtendedContext = true + + result.push(entry) + } + } } } @@ -49,9 +101,16 @@ const mod: CoverageProviderModule = { return } + const session = this.session as inspector.Session + + if (!session) { + throw new Error('V8 provider missing inspector session.') + } + await session.post('Profiler.stopPreciseCoverage') await session.post('Profiler.disable') session.disconnect() + this.session = null }, async getProvider(): Promise { diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index f46be5d9b..be4a0c382 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -20,6 +20,9 @@ import { version } from '../package.json' with { type: 'json' } export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage { startOffset: number + + /** Whether script ran outside Vite, e.g. in sub-processes or worker threads */ + isExtendedContext?: boolean } interface RawCoverage { result: ScriptCoverageWithOffset[] } @@ -34,6 +37,18 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage initialize(ctx: Vitest): void { this._initialize(ctx) + + if (this.options.autoAttachSubprocess) { + const isAnyThreadsPools = ctx.projects.some(p => p.config.pool === 'threads' || p.config.pool === 'vmThreads') + + if (isAnyThreadsPools) { + // Work-around for https://github.com/nodejs/node/issues/46378 + // Node never does anything with this directory, it's just required so that + // the next Workers read **their** env.NODE_V8_COVERAGE. + // Node never creates this .unused directory at all. + process.env.NODE_V8_COVERAGE = `${this.coverageFilesDirectory}/.unused` + } + } } createCoverageMap(): CoverageMap { @@ -46,16 +61,26 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage const coverageMap = this.createCoverageMap() let merged: RawCoverage = { result: [] } + const autoAttachSubprocess = this.options.autoAttachSubprocess + await this.readCoverageFiles({ onFileRead(coverage) { merged = mergeProcessCovs([merged, coverage]) + // mergeProcessCovs sometimes loses autoAttachSubprocess + const fromExtendedContext = autoAttachSubprocess ? coverage.result.filter(r => r.isExtendedContext) : [] + // mergeProcessCovs sometimes loses startOffset, e.g. in vue merged.result.forEach((result) => { if (!result.startOffset) { const original = coverage.result.find(r => r.url === result.url) result.startOffset = original?.startOffset || 0 } + + if (autoAttachSubprocess && !result.isExtendedContext) { + const actual = fromExtendedContext.find(r => r.url === result.url) + result.isExtendedContext = actual?.isExtendedContext + } }) }, onFinished: async (project, environment) => { @@ -331,8 +356,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage private async getSources( url: string, - onTransform: (filepath: string) => Promise, + onTransform: (filepath: string, isExtendedContext?: ScriptCoverageWithOffset['isExtendedContext']) => Promise, functions: Profiler.FunctionCoverage[] = [], + isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false, ): Promise<{ code: string map?: Vite.Rollup.SourceMap @@ -342,7 +368,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage ? url.slice(8) : removeStartsWith(url, FILE_PROTOCOL) // TODO: do we still need to "catch" here? why would it fail? - const transformResult = await onTransform(filepath).catch(() => null) + const transformResult = await onTransform(filepath, isExtendedContext).catch(() => null) const map = transformResult?.map as Vite.Rollup.SourceMap | undefined const code = transformResult?.code @@ -385,8 +411,8 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage throw new Error(`Cannot access browser module graph because it was torn down.`) } - const onTransform = async (filepath: string) => { - const result = await this.transformFile(filepath, project, environment) + const onTransform = async (filepath: string, isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false) => { + const result = await this.transformFile(filepath, project, environment, !isExtendedContext) if (result && environment === '__browser__' && project.browser) { return { ...result, code: `${result.code}// ` } } @@ -423,7 +449,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage } await Promise.all( - chunk.map(async ({ url, functions, startOffset }) => { + chunk.map(async ({ url, functions, startOffset, isExtendedContext }) => { let timeout: ReturnType | undefined let start: number | undefined @@ -436,6 +462,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage url, onTransform, functions, + isExtendedContext, ) coverageMap.merge(await this.remapCoverage( diff --git a/packages/vitest/src/defaults.ts b/packages/vitest/src/defaults.ts index 7471d73cc..5d44007c0 100644 --- a/packages/vitest/src/defaults.ts +++ b/packages/vitest/src/defaults.ts @@ -53,6 +53,7 @@ export const coverageConfigDefaults: Required = { diff --git a/packages/vitest/src/integrations/coverage.ts b/packages/vitest/src/integrations/coverage.ts index df9681490..85b5e60ec 100644 --- a/packages/vitest/src/integrations/coverage.ts +++ b/packages/vitest/src/integrations/coverage.ts @@ -3,21 +3,25 @@ import type { RuntimeCoverageModuleLoader } from '../utils/coverage' import { resolveCoverageProviderModule } from '../utils/coverage' export async function startCoverageInsideWorker( - options: SerializedCoverageConfig | undefined, + options: SerializedCoverageConfig, loader: RuntimeCoverageModuleLoader, runtimeOptions: { isolate: boolean }, ): Promise { const coverageModule = await resolveCoverageProviderModule(options, loader) if (coverageModule) { - return coverageModule.startCoverage?.(runtimeOptions) + return coverageModule.startCoverage?.({ + ...runtimeOptions, + autoAttachSubprocess: options.autoAttachSubprocess, + reportsDirectory: options.reportsDirectory, + }) } return null } export async function takeCoverageInsideWorker( - options: SerializedCoverageConfig | undefined, + options: SerializedCoverageConfig, loader: RuntimeCoverageModuleLoader, ): Promise { const coverageModule = await resolveCoverageProviderModule(options, loader) @@ -30,7 +34,7 @@ export async function takeCoverageInsideWorker( } export async function stopCoverageInsideWorker( - options: SerializedCoverageConfig | undefined, + options: SerializedCoverageConfig, loader: RuntimeCoverageModuleLoader, runtimeOptions: { isolate: boolean }, ): Promise { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 573d10cb4..fab428d3f 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -326,6 +326,9 @@ export const cliOptionsConfig: VitestCLIOptions = { description: 'Directory of HTML coverage output to be served in UI mode and HTML reporter.', argument: '', }, + autoAttachSubprocess: { + description: 'Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. Supported only by `v8` provider. (default: false)', + }, }, }, mode: { diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 4904fdf00..b2b3e8f16 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -1,6 +1,7 @@ import type { SerializedConfig } from '../../runtime/config' import type { TestProject } from '../project' import type { ApiConfig } from '../types/config' +import { resolve } from 'node:path' import { configDefaults } from '../../defaults' import { isAgent } from '../../utils/env' @@ -54,13 +55,14 @@ export function serializeConfig(project: TestProject): SerializedConfig { passWithNoTests: config.passWithNoTests, coverage: ((coverage) => { return { - reportsDirectory: coverage.reportsDirectory, + reportsDirectory: resolve(globalConfig.root, coverage.reportsDirectory), provider: coverage.provider, enabled: coverage.enabled, customProviderModule: 'customProviderModule' in coverage ? coverage.customProviderModule : undefined, htmlDir: coverage.htmlDir, + autoAttachSubprocess: coverage.autoAttachSubprocess ?? false, } })(config.coverage), fakeTimers: config.fakeTimers, diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index e05ffca58..f17a15f8d 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -692,11 +692,11 @@ export class BaseCoverageProvider { // TODO: should this be abstracted in `project`/`vitest` instead? // if we decide to keep `viteModuleRunner: false`, we will need to abstract transformation in both main thread and tests // custom --import=module.registerHooks need to be transformed as well somehow - async transformFile(url: string, project: TestProject, viteEnvironment: string): Promise { + async transformFile(url: string, project: TestProject, viteEnvironment: string, isTransformedByVite = true): Promise { const config = project.config // vite is disabled, should transform manually if possible - if (config.experimental.viteModuleRunner === false) { + if (config.experimental.viteModuleRunner === false || !isTransformedByVite) { const pathname = url.split('?')[0] const filename = pathname.startsWith('file://') ? fileURLToPath(pathname) : pathname const extension = path.extname(filename) diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 1c252b1d9..0d34c3c6c 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -111,6 +111,7 @@ export type FieldsWithDefaultValues | 'ignoreClassMethods' | 'skipFull' | 'watermarks' + | 'autoAttachSubprocess' export type ResolvedCoverageOptions = CoverageOptions @@ -264,6 +265,14 @@ export interface CoverageOptions { */ processingConcurrency?: number + /** + * Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. + * Supported only by `v8` provider. + * + * @default false + */ + autoAttachSubprocess?: boolean + /** * Set to array of class method names to ignore for coverage * diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 975f0b9cd..5df14bd94 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -164,6 +164,7 @@ export interface SerializedCoverageConfig { htmlDir: string | undefined enabled: boolean customProviderModule: string | undefined + autoAttachSubprocess: boolean } export interface SerializedRootConfig extends SerializedConfig { diff --git a/packages/vitest/src/utils/coverage.ts b/packages/vitest/src/utils/coverage.ts index b3a1d23c2..49b9ac982 100644 --- a/packages/vitest/src/utils/coverage.ts +++ b/packages/vitest/src/utils/coverage.ts @@ -15,7 +15,13 @@ export interface RuntimeCoverageProviderModule { /** * Executed before tests are run in the worker thread. */ - startCoverage?: (runtimeOptions: { isolate: boolean }) => unknown | Promise + startCoverage?: (runtimeOptions: { + isolate: boolean + /** @internal */ + autoAttachSubprocess: boolean + /** @internal */ + reportsDirectory: string + }) => unknown | Promise /** * Executed on after each run in the worker thread. Possible to return a payload passed to the provider diff --git a/test/coverage-test/fixtures/src/math-in-js.js b/test/coverage-test/fixtures/src/math-in-js.js new file mode 100644 index 000000000..f4a7d3dfe --- /dev/null +++ b/test/coverage-test/fixtures/src/math-in-js.js @@ -0,0 +1,15 @@ +export function sum(a, b) { + return a + b +} + +export function subtract(a, b) { + return a - b +} + +export function multiply(a, b) { + return a * b +} + +export function remainder(a, b) { + return a % b +} diff --git a/test/coverage-test/fixtures/src/math.ts b/test/coverage-test/fixtures/src/math.ts index 5d335c238..b85ebb9e6 100644 --- a/test/coverage-test/fixtures/src/math.ts +++ b/test/coverage-test/fixtures/src/math.ts @@ -10,6 +10,6 @@ export function multiply(a: number, b: number) { return a * b } -export function remainder(a: number, b:number) { +export function remainder(a: number, b: number) { return a % b } diff --git a/test/coverage-test/fixtures/src/start-fork-and-thread.ts b/test/coverage-test/fixtures/src/start-fork-and-thread.ts new file mode 100644 index 000000000..c87d687a2 --- /dev/null +++ b/test/coverage-test/fixtures/src/start-fork-and-thread.ts @@ -0,0 +1,27 @@ +import { fork } from 'node:child_process'; +import { resolve } from 'node:path'; +import { Worker } from 'node:worker_threads'; + +export async function runFork(filename: string): Promise { + const child = fork(resolve(import.meta.dirname, filename)); + const onExit = new Promise((resolve) => child.on('exit', resolve)); + + const onResponse = new Promise((resolve) => child.on('message', resolve)); + const response = await onResponse; + + await onExit; + + return response; +} + +export async function runThread(filename: string): Promise { + const worker = new Worker(resolve(import.meta.dirname, filename)); + const onExit = new Promise((resolve) => worker.on('exit', resolve)); + + const onResponse = new Promise((resolve) => worker.on('message', resolve)); + const response = await onResponse; + + await onExit; + + return response; +} \ No newline at end of file diff --git a/test/coverage-test/fixtures/src/worker-or-process.js b/test/coverage-test/fixtures/src/worker-or-process.js new file mode 100644 index 000000000..6a8e2a58d --- /dev/null +++ b/test/coverage-test/fixtures/src/worker-or-process.js @@ -0,0 +1,30 @@ +import { isMainThread, parentPort } from "node:worker_threads"; +import { subtract } from "./math-in-js.js"; + +/** + * Comment that adds padding + */ +function execute(options) { + return subtract(options.left, options.right); +} + +const result = execute({ left: 10, right: 5 }); + +if (isMainThread && process.send) { + // Comment + // Comment + process.send({ result }); + // Comment +} +else if (parentPort) { + // Comment + // Comment + // Comment + // Comment + parentPort.postMessage({ result }); +} +else { + // Comment + // Comment + throw new Error("Where is this running?") +} diff --git a/test/coverage-test/fixtures/src/worker-or-process.nested.js b/test/coverage-test/fixtures/src/worker-or-process.nested.js new file mode 100644 index 000000000..a81b04e27 --- /dev/null +++ b/test/coverage-test/fixtures/src/worker-or-process.nested.js @@ -0,0 +1,42 @@ +import { isMainThread, parentPort, Worker } from "node:worker_threads"; +import { fork } from "node:child_process"; +import { subtract } from "./math-in-js.js"; + +const isNested = process.env.IS_NESTED === "true"; +const isMain = !isNested; + +const isMainChildProcess = isMain && process.send != null; +const isMainWorkerThread = isMain && !isMainThread; + +const isNestedChildProcess = isNested && process.send != null; +const isNestedWorkerThread = isNested && !isMainThread; + +if(isMainChildProcess) { + const child = fork(import.meta.filename, { env: { ...process.env, IS_NESTED: "true" } }); + const onExit = new Promise((resolve) => child.on('exit', resolve)); + + const onResponse = new Promise((resolve) => child.on('message', resolve)); + const result = await onResponse; + + await onExit; + process.send({ result }); +} +else if (isNestedChildProcess) { + process.send(subtract(10, 5)); +} +else if(isMainWorkerThread) { + const worker = new Worker(import.meta.filename, { env: { ...process.env, IS_NESTED: "true" } }); + const onExit = new Promise((resolve) => worker.on('exit', resolve)); + + const onResponse = new Promise((resolve) => worker.on('message', resolve)); + const result = await onResponse; + + await onExit; + parentPort?.postMessage({ result }); +} +else if(isNestedWorkerThread) { + parentPort.postMessage(subtract(10, 5)); +} +else { + throw new Error("Where is this running?"); +} diff --git a/test/coverage-test/fixtures/src/worker-or-process.pre-transpiled.js b/test/coverage-test/fixtures/src/worker-or-process.pre-transpiled.js new file mode 100644 index 000000000..5ccb1c953 --- /dev/null +++ b/test/coverage-test/fixtures/src/worker-or-process.pre-transpiled.js @@ -0,0 +1,14 @@ +import { isMainThread, parentPort } from "node:worker_threads"; +import { hello } from "./pre-transpiled/transpiled.js"; + +hello(); + +if (isMainThread && process.send) { + process.send({ result: "finished" }); +} +else if (parentPort) { + parentPort.postMessage({ result: "finished" }); +} +else { + throw new Error("Where is this running?") +} diff --git a/test/coverage-test/fixtures/src/worker-or-process.ts b/test/coverage-test/fixtures/src/worker-or-process.ts new file mode 100644 index 000000000..bdff704d6 --- /dev/null +++ b/test/coverage-test/fixtures/src/worker-or-process.ts @@ -0,0 +1,45 @@ +import { isMainThread, parentPort } from "node:worker_threads"; +import { subtract } from "./math.ts"; + +interface Options { + left: number; + right: number; +} + +/** + * Comment that adds padding + */ +function execute(options: Options) { + return subtract(options.left, options.right); +} + +const result = execute({ left: 10, right: 5 }); + +if (isMainThread && process.send) { + // Comment + interface Padding { + left: number; + right: number; + } + // Comment + process.send({ result }); + interface Padding2 { + left: number; + right: number; + } + // Comment +} +else if (parentPort) { + // Comment + // Comment + // Comment + // Comment + parentPort.postMessage({ result }); +} +else { + // Comment + type A = "B" + type B = "C" + // Comment + throw new Error("Where is this running?" as unknown as A | B) +} diff --git a/test/coverage-test/fixtures/test/child-process.test.ts b/test/coverage-test/fixtures/test/child-process.test.ts new file mode 100644 index 000000000..03e9d7233 --- /dev/null +++ b/test/coverage-test/fixtures/test/child-process.test.ts @@ -0,0 +1,24 @@ +import { test , expect } from "vitest" +import { runFork } from "../src/start-fork-and-thread"; + +const isTypeStrippingSupported = !!process.features.typescript + +test.runIf(isTypeStrippingSupported)("child process typescript", async () => { + const { result } = await runFork('worker-or-process.ts'); + expect(result).toBe(5); +}) + +test("child process javascript source file", async () => { + const { result } = await runFork('worker-or-process.js'); + expect(result).toBe(5); +}) + +test("child process transpiled javascript with source maps", async () => { + const { result } = await runFork('worker-or-process.pre-transpiled.js'); + expect(result).toBe("finished"); +}) + +test("child process inside child process", async () => { + const { result } = await runFork('worker-or-process.nested.js'); + expect(result).toBe(5); +}) diff --git a/test/coverage-test/fixtures/test/worker-thread.test.ts b/test/coverage-test/fixtures/test/worker-thread.test.ts new file mode 100644 index 000000000..b02f77baa --- /dev/null +++ b/test/coverage-test/fixtures/test/worker-thread.test.ts @@ -0,0 +1,24 @@ +import { test , expect } from "vitest" +import { runThread } from "../src/start-fork-and-thread"; + +const isTypeStrippingSupported = !!process.features.typescript + +test.runIf(isTypeStrippingSupported)("worker thread typescript", async () => { + const { result } = await runThread('worker-or-process.ts'); + expect(result).toBe(5); +}) + +test("worker thread javascript source file", async () => { + const { result } = await runThread('worker-or-process.js'); + expect(result).toBe(5); +}) + +test("worker thread transpiled javascript with source maps", async () => { + const { result } = await runThread('worker-or-process.pre-transpiled.js'); + expect(result).toBe("finished"); +}) + +test("worker thread inside worker thread", async () => { + const { result } = await runThread('worker-or-process.nested.js', ); + expect(result).toBe(5); +}) diff --git a/test/coverage-test/test/configuration-options.test-d.ts b/test/coverage-test/test/configuration-options.test-d.ts index 20acb62b6..96c0f49bf 100644 --- a/test/coverage-test/test/configuration-options.test-d.ts +++ b/test/coverage-test/test/configuration-options.test-d.ts @@ -100,7 +100,7 @@ test('provider module', () => { branches: [80, 95], lines: [80, 95], }, - + autoAttachSubprocess: false, } }, clean(_?: boolean) {}, diff --git a/test/coverage-test/test/extended-run-context.v8.test.ts b/test/coverage-test/test/extended-run-context.v8.test.ts new file mode 100644 index 000000000..4331e0254 --- /dev/null +++ b/test/coverage-test/test/extended-run-context.v8.test.ts @@ -0,0 +1,216 @@ +import { describe, expect } from 'vitest' +import { readCoverageMap, runVitest, test } from '../utils' + +const isTypeStrippingSupported = !!process.features.typescript + +describe.each(['child process', 'worker thread'] as const)('%s', (runtime) => { + /* See {@link file://./../fixtures/test/child-process.test.ts} */ + /* See {@link file://./../fixtures/test/worker-thread.test.ts} */ + const filename = `fixtures/test/${runtime.replace(' ', '-')}.test.ts` + + test('{ autoAttachSubprocess: true } typescript source file', async ({ skip }) => { + skip(isTypeStrippingSupported === false, `Type stripping is not supported in Node ${process.version}`) + + await runVitest({ + include: [filename], + testNamePattern: `${runtime} typescript`, + pool: 'forks', + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/math.ts", + "/fixtures/src/start-fork-and-thread.ts", + "/fixtures/src/worker-or-process.ts", + ] + `) + + /* See {@link file://./../fixtures/src/worker-or-process.ts} */ + const fileCoverage = coverageMap.fileCoverageFor('/fixtures/src/worker-or-process.ts') + const lines = fileCoverage.getLineCoverage() + + expect.soft(lines[25]).toBe(runtime === 'child process' ? 1 : 0) + expect.soft(lines[37]).toBe(runtime === 'worker thread' ? 1 : 0) + expect.soft(lines[44]).toBe(0) + + assertMath(coverageMap) + }) + + test('{ autoAttachSubprocess: true } javascript source file', async () => { + await runVitest({ + include: [filename], + testNamePattern: `${runtime} javascript source file`, + pool: 'forks', + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/math-in-js.js", + "/fixtures/src/start-fork-and-thread.ts", + "/fixtures/src/worker-or-process.js", + ] + `) + + { + /* See {@link file://./../fixtures/src/worker-or-process.js} */ + const fileCoverage = coverageMap.fileCoverageFor('/fixtures/src/worker-or-process.js') + const lines = fileCoverage.getLineCoverage() + + expect.soft(lines[16]).toBe(runtime === 'child process' ? 1 : 0) + expect.soft(lines[24]).toBe(runtime === 'worker thread' ? 1 : 0) + expect.soft(lines[29]).toBe(0) + } + + assertMath(coverageMap, 'math-in-js.js') + }) + + test('{ autoAttachSubprocess: true } pre-transpiled file', async () => { + await runVitest({ + include: [filename], + testNamePattern: `(${runtime} transpiled javascript with source maps)`, + pool: 'forks', + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/start-fork-and-thread.ts", + "/fixtures/src/worker-or-process.pre-transpiled.js", + "/fixtures/src/pre-transpiled/original.ts", + ] + `) + + /* See {@link file://./../fixtures/src/pre-transpiled/original.ts} */ + const fileCoverage = coverageMap.fileCoverageFor('/fixtures/src/pre-transpiled/original.ts') + const lines = fileCoverage.getLineCoverage() + + expect.soft(lines[4]).toBe(0) + expect.soft(lines[8]).toBe(1) + expect.soft(lines[12]).toBe(0) + expect.soft(lines[17]).toBe(1) + }) + + test(`{ autoAttachSubprocess: true } nested ${runtime}'s`, async () => { + await runVitest({ + include: [filename], + testNamePattern: `(${runtime} inside ${runtime})`, + pool: 'forks', + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/math-in-js.js", + "/fixtures/src/start-fork-and-thread.ts", + "/fixtures/src/worker-or-process.nested.js", + ] + `) + + assertMath(coverageMap, 'math-in-js.js') + }) + + test('{ autoAttachSubprocess: true, isolate: false, fileParallelism: false }', async () => { + await runVitest({ + include: [filename, `fixtures/test/even.test.ts`], + testNamePattern: `${runtime} javascript source file`, + pool: 'forks', + isolate: false, + fileParallelism: false, + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/even.ts", + "/fixtures/src/math-in-js.js", + "/fixtures/src/start-fork-and-thread.ts", + "/fixtures/src/worker-or-process.js", + ] + `) + + assertMath(coverageMap, 'math-in-js.js') + }) + + test('{ autoAttachSubprocess: false }', async () => { + await runVitest({ + include: [filename], + pool: 'forks', + coverage: { + autoAttachSubprocess: false, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/start-fork-and-thread.ts", + ] + `) + }) + + test('{ autoAttachSubprocess: true } when no child contexts are spawned', async () => { + await runVitest({ + include: ['fixtures/test/math.test.ts'], + pool: 'forks', + coverage: { + autoAttachSubprocess: true, + reporter: 'json', + }, + }) + const coverageMap = await readCoverageMap() + const files = coverageMap.files() + + expect(files).toMatchInlineSnapshot(` + [ + "/fixtures/src/math.ts", + ] + `) + }) +}) + +function assertMath(coverageMap: Awaited>, filename: 'math.ts' | 'math-in-js.js' = 'math.ts') { + /* See {@link file://./../fixtures/src/math-in-js.js} */ + const fileCoverage = coverageMap.fileCoverageFor(`/fixtures/src/${filename}`) + const lines = fileCoverage.getLineCoverage() + + // Sum + expect.soft(lines[2]).toBe(0) + + // Subtract + expect.soft(lines[6]).toBe(1) + + // Multiply + expect.soft(lines[10]).toBe(0) + + // Divide + expect.soft(lines[14]).toBe(0) +} diff --git a/test/coverage-test/utils.ts b/test/coverage-test/utils.ts index 3aebf83d1..18e2f50bc 100644 --- a/test/coverage-test/utils.ts +++ b/test/coverage-test/utils.ts @@ -11,7 +11,7 @@ import { playwright } from '@vitest/browser-playwright' import { toArray } from '@vitest/utils/helpers' import libCoverage from 'istanbul-lib-coverage' import { normalize } from 'pathe' -import { onTestFailed, TestRunner, vi, describe as vitestDescribe, test as vitestTest } from 'vitest' +import { onTestFailed, onTestFinished, TestRunner, vi, describe as vitestDescribe, test as vitestTest } from 'vitest' import * as testUtils from '../test-utils/index' export const test: TestAPI = process.env.COVERAGE_TEST !== 'true' @@ -78,6 +78,12 @@ export async function runVitest(config: TestUserConfig, options = { throwOnError console.error('stderr:', result.stderr) console.error('stdout:', result.stdout) }) + + onTestFinished(() => { + if (process.env.NODE_V8_COVERAGE) { + delete process.env.NODE_V8_COVERAGE + } + }) } if (options.throwOnError) {