Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import type { CoverageModuleLoader, CoverageOptions, CoverageProvider, ReportCon
import type { SerializedCoverageConfig } from '../runtime/config'
import type { AfterSuiteRunMeta } from '../types/general'
import type { TestProject } from './project'
import { createHash } from 'node:crypto'
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
import module from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { cleanUrl, slash } from '@vitest/utils/helpers'
Expand Down Expand Up @@ -74,6 +76,16 @@ export async function getCoverageProvider(
return null
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
Comment thread
AriPerkkio marked this conversation as resolved.
Outdated
return true
}
catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}

export class BaseCoverageProvider {
ctx!: Vitest
readonly name!: 'v8' | 'istanbul'
Expand Down Expand Up @@ -240,6 +252,8 @@ export class BaseCoverageProvider {
}

async clean(clean = true): Promise<void> {
await this.acquireReportsDirectoryLock()

if (clean && existsSync(this.options.reportsDirectory)) {
await fs.rm(this.options.reportsDirectory, {
recursive: true,
Expand All @@ -262,6 +276,84 @@ export class BaseCoverageProvider {
this.pendingPromises = []
}

private get reportsDirectoryLockFile(): string {
Comment thread
jgamaraalv marked this conversation as resolved.
Outdated
const hash = createHash('sha256')
.update(resolve(this.options.reportsDirectory))
.digest('hex')
.slice(0, 16)

return resolve(tmpdir(), `vitest-coverage-${hash}.lock`)
}

private async acquireReportsDirectoryLock(): Promise<void> {
Comment thread
jgamaraalv marked this conversation as resolved.
Outdated
const lockFile = this.reportsDirectoryLockFile
const payload = JSON.stringify({
pid: process.pid,
reportsDirectory: resolve(this.options.reportsDirectory),
timestamp: Date.now(),
})

for (let attempt = 0; attempt < 10; attempt++) {
Comment thread
AriPerkkio marked this conversation as resolved.
Outdated
try {
await fs.writeFile(lockFile, payload, { flag: 'wx' })
return
}
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error
}
}

const owner = await this.readReportsDirectoryLockOwner(lockFile)

if (owner?.pid === process.pid) {
return
}

if (owner == null || !isProcessAlive(owner.pid)) {
await fs.rm(lockFile, { force: true })
continue
}

throw new Error(
`The coverage report directory "${this.options.reportsDirectory}" is already in use by `
+ `another Vitest process (pid ${owner.pid}). Running coverage for multiple Vitest processes `
+ `in the same directory at the same time is not supported, because they would delete each `
+ `other's reports.\nGive each run its own "coverage.reportsDirectory" `
+ `(e.g. --coverage.reportsDirectory=coverage-${process.pid}) or run them sequentially.`,
)
}

throw new Error(
`Could not acquire the coverage report directory lock for "${this.options.reportsDirectory}". `
+ `Give each run its own "coverage.reportsDirectory" or run them sequentially.`,
)
Comment thread
AriPerkkio marked this conversation as resolved.
Outdated
}

private async readReportsDirectoryLockOwner(
lockFile: string,
): Promise<{ pid: number } | null> {
try {
const owner = JSON.parse(await fs.readFile(lockFile, 'utf-8'))

if (typeof owner?.pid === 'number') {
return owner
}
}
catch {}

return null
}

private async releaseReportsDirectoryLock(): Promise<void> {
const lockFile = this.reportsDirectoryLockFile
const owner = await this.readReportsDirectoryLockOwner(lockFile)

if (owner == null || owner.pid === process.pid) {
await fs.rm(lockFile, { force: true })
}
}

private normalizeCoverageFileError(error: unknown): unknown {
if (
error instanceof Error
Expand Down Expand Up @@ -349,12 +441,17 @@ export class BaseCoverageProvider {
}

async cleanAfterRun(): Promise<void> {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })
try {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })

// Remove empty reports directory, e.g. when only text-reporter is used
if (readdirSync(this.options.reportsDirectory).length === 0) {
await fs.rm(this.options.reportsDirectory, { recursive: true })
// Remove empty reports directory, e.g. when only text-reporter is used
if (readdirSync(this.options.reportsDirectory).length === 0) {
await fs.rm(this.options.reportsDirectory, { recursive: true })
}
}
finally {
await this.releaseReportsDirectoryLock()
}
}

Expand Down
85 changes: 83 additions & 2 deletions test/coverage-test/test/temporary-files.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import { resolve } from 'node:path'
import { expect, test } from 'vitest'
import { spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, expect, test } from 'vitest'
import { BaseCoverageProvider } from 'vitest/node'

const cleanups: Array<() => void> = []

afterEach(() => {
while (cleanups.length) {
cleanups.pop()!()
}
})
Comment thread
AriPerkkio marked this conversation as resolved.
Outdated

function createProvider() {
const reportsDirectory = mkdtempSync(join(tmpdir(), 'vitest-coverage-reports-'))
const provider = new BaseCoverageProvider()
provider.coverageFilesDirectory = join(reportsDirectory, '.tmp')
provider.options = { reportsDirectory } as any

const lockFile = (provider as any).reportsDirectoryLockFile as string

cleanups.push(() => {
rmSync(reportsDirectory, { recursive: true, force: true })
rmSync(lockFile, { force: true })
})

return { provider, reportsDirectory, lockFile }
}

test('missing coverage temp directory throws an actionable error', async () => {
const provider = new BaseCoverageProvider()
provider.coverageFilesDirectory = resolve('missing-coverage-directory', '.tmp')
Expand All @@ -17,3 +44,57 @@ test('missing coverage temp directory throws an actionable error', async () => {
`Something removed the coverage directory "${provider.coverageFilesDirectory}" Vitest created earlier. Make sure you are not running multiple Vitests with the same "coverage.reportsDirectory" at the same time.`,
)
})

test('clean() acquires the reportsDirectory lock and cleanAfterRun() releases it', async () => {
const { provider, lockFile } = createProvider()

await provider.clean(true)

expect(existsSync(provider.coverageFilesDirectory)).toBe(true)
expect(existsSync(lockFile)).toBe(true)
expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(process.pid)

await provider.cleanAfterRun()

expect(existsSync(lockFile)).toBe(false)
})

test('clean() is re-entrant for the same process (e.g. watch mode reruns)', async () => {
const { provider } = createProvider()

await provider.clean(true)

await expect(provider.clean(true)).resolves.toBeUndefined()

await provider.cleanAfterRun()
})

test('clean() throws an actionable error when another live process holds the lock', async () => {
const { provider, reportsDirectory, lockFile } = createProvider()

const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
await new Promise(resolve => child.once('spawn', resolve))
cleanups.push(() => child.kill())

writeFileSync(lockFile, JSON.stringify({ pid: child.pid, reportsDirectory, timestamp: Date.now() }))

await expect(provider.clean(true)).rejects.toThrow('is already in use by another Vitest process')

expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(child.pid)
})

test('clean() reclaims a stale lock left by a process that no longer exists', async () => {
const { provider, reportsDirectory, lockFile } = createProvider()

const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' })
await new Promise(resolve => child.once('exit', resolve))
const deadPid = child.pid!

writeFileSync(lockFile, JSON.stringify({ pid: deadPid, reportsDirectory, timestamp: Date.now() }))

await expect(provider.clean(true)).resolves.toBeUndefined()

expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(process.pid)

await provider.cleanAfterRun()
})
Loading