Skip to content

Commit

Permalink
feat!: remove usage of c8 private APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed May 10, 2023
1 parent ddbba39 commit 91c096b
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 248 deletions.
27 changes: 0 additions & 27 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -843,33 +843,6 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-threshol
Threshold for statements.
See [istanbul documentation](https://github.com/istanbuljs/nyc#coverage-thresholds) for more information.

#### coverage.allowExternal

- **Type:** `boolean`
- **Default:** `false`
- **Available for providers:** `'c8'`
- **CLI:** `--coverage.allowExternal`, `--coverage.allowExternal=false`

Allow files from outside of your cwd.

#### coverage.excludeNodeModules

- **Type:** `boolean`
- **Default:** `true`
- **Available for providers:** `'c8'`
- **CLI:** `--coverage.excludeNodeModules`, `--coverage.excludeNodeModules=false`

Exclude coverage under `/node_modules/`.

#### coverage.src

- **Type:** `string[]`
- **Default:** `process.cwd()`
- **Available for providers:** `'c8'`
- **CLI:** `--coverage.src=<path>`

Specifies the directories that are used when `--all` is enabled.

#### coverage.100

- **Type:** `boolean`
Expand Down
16 changes: 13 additions & 3 deletions packages/coverage-c8/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,26 @@
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"vitest": ">=0.30.0 <1"
"vitest": ">=0.32.0 <1"
},
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"c8": "^7.13.0",
"@bcoe/v8-coverage": "^0.2.3",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.0",
"istanbul-lib-source-maps": "^4.0.1",
"istanbul-reports": "^3.1.5",
"magic-string": "^0.30.0",
"picocolors": "^1.0.0",
"std-env": "^3.3.2"
"std-env": "^3.3.2",
"test-exclude": "^6.0.0",
"v8-to-istanbul": "^9.1.0"
},
"devDependencies": {
"@types/istanbul-lib-coverage": "^2.0.4",
"@types/istanbul-lib-report": "^3.0.0",
"@types/istanbul-lib-source-maps": "^4.0.1",
"@types/istanbul-reports": "^3.0.1",
"pathe": "^1.1.0",
"vite-node": "workspace:*",
"vitest": "workspace:*"
Expand Down
264 changes: 148 additions & 116 deletions packages/coverage-c8/src/provider.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
import { existsSync, promises as fs } from 'node:fs'
import _url from 'node:url'
import type { Profiler } from 'node:inspector'
import { fileURLToPath, pathToFileURL } from 'node:url'
import v8ToIstanbul from 'v8-to-istanbul'
import { mergeProcessCovs } from '@bcoe/v8-coverage'
import libReport from 'istanbul-lib-report'
import reports from 'istanbul-reports'
import type { CoverageMap } from 'istanbul-lib-coverage'
import libCoverage from 'istanbul-lib-coverage'
import libSourceMaps from 'istanbul-lib-source-maps'
import MagicString from 'magic-string'
import remapping from '@ampproject/remapping'
import { extname, resolve } from 'pathe'
import { normalize, resolve } from 'pathe'
import c from 'picocolors'
import { provider } from 'std-env'
import type { EncodedSourceMap } from 'vite-node'
import { coverageConfigDefaults } from 'vitest/config'
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
import { BaseCoverageProvider } from 'vitest/coverage'
import type { AfterSuiteRunMeta, CoverageC8Options, CoverageProvider, ReportContext, ResolvedCoverageOptions } from 'vitest'
import type { Vitest } from 'vitest/node'
import type { Report } from 'c8'

// @ts-expect-error missing types
import createReport from 'c8/lib/report.js'

// @ts-expect-error missing types
import { checkCoverages } from 'c8/lib/commands/check-coverage.js'
import _TestExclude from 'test-exclude'

interface TestExclude {
new(opts: {
cwd?: string | string[]
include?: string | string[]
exclude?: string | string[]
extension?: string | string[]
excludeNodeModules?: boolean
}): {
shouldInstrument(filePath: string): boolean
glob(cwd: string): Promise<string[]>
}
}

type Options = ResolvedCoverageOptions<'c8'>

// TODO: vite-node should export this
const WRAPPER_LENGTH = 185

// Note that this needs to match the line ending as well
const VITE_EXPORTS_LINE_PATTERN = /Object\.defineProperty\(__vite_ssr_exports__.*\n/g

Expand All @@ -29,6 +48,7 @@ export class C8CoverageProvider extends BaseCoverageProvider implements Coverage

ctx!: Vitest
options!: Options
testExclude!: InstanceType<TestExclude>
coverages: Profiler.TakePreciseCoverageReturnType[] = []

initialize(ctx: Vitest) {
Expand All @@ -38,10 +58,6 @@ export class C8CoverageProvider extends BaseCoverageProvider implements Coverage
this.options = {
...coverageConfigDefaults,

// Provider specific defaults
excludeNodeModules: true,
allowExternal: false,

// User's options
...config,

Expand All @@ -54,6 +70,14 @@ export class C8CoverageProvider extends BaseCoverageProvider implements Coverage
branches: config['100'] ? 100 : config.branches,
statements: config['100'] ? 100 : config.statements,
}

this.testExclude = new _TestExclude({
cwd: ctx.config.root,
include: typeof this.options.include === 'undefined' ? undefined : [...this.options.include],
exclude: [...defaultExclude, ...defaultInclude, ...this.options.exclude],
excludeNodeModules: true,
extension: this.options.extension,
})
}

resolveOptions() {
Expand All @@ -75,122 +99,70 @@ export class C8CoverageProvider extends BaseCoverageProvider implements Coverage
if (provider === 'stackblitz')
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-c8 does not work on Stackblitz. Report will be empty.'))

const options: ConstructorParameters<typeof Report>[0] = {
...this.options,
all: this.options.all && allTestsRun,
reporter: this.options.reporter.map(([reporterName]) => reporterName),
reporterOptions: this.options.reporter.reduce((all, [name, options]) => ({
...all,
[name]: {
skipFull: this.options.skipFull,
projectRoot: this.ctx.config.root,
...options,
},
}), {}),
}

const report = createReport(options)
const { result: scriptCoverages } = mergeProcessCovs(this.coverages.map(coverage => ({
result: coverage.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url))),
})))

// Overwrite C8's loader as results are in memory instead of file system
report._loadReports = () => this.coverages
if (this.options.all && allTestsRun) {
const coveredFiles = Array.from(scriptCoverages.map(r => r.url))
const untestedFiles = await this.getUntestedFiles(coveredFiles)

interface MapAndSource { map: EncodedSourceMap; source: string | undefined }
type SourceMapMeta = { url: string; filepath: string } & MapAndSource

// add source maps
const sourceMapMeta: Record<SourceMapMeta['url'], MapAndSource> = {}
const extensions = Array.isArray(this.options.extension) ? this.options.extension : [this.options.extension]

const fetchCache = this.ctx.projects.map(project =>
Array.from(project.vitenode.fetchCache.entries()),
).flat()

const entries = Array
.from(fetchCache)
.filter(entry => report._shouldInstrument(entry[0]))
.map(([file, { result }]) => {
if (!result.map)
return null
scriptCoverages.push(...untestedFiles)
}

const filepath = file.split('?')[0]
const url = _url.pathToFileURL(filepath).href
const extension = extname(file) || extname(url)
const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => {
const sources = await this.getSources(url)

return {
filepath,
url,
extension,
map: result.map,
source: result.code,
}
})
.filter((entry) => {
if (!entry)
return false

if (!extensions.includes(entry.extension))
return false

// Mappings and sourcesContent are needed for C8 to work
return (
entry.map.mappings.length > 0
&& entry.map.sourcesContent
&& entry.map.sourcesContent.length > 0
&& entry.map.sourcesContent[0]
&& entry.map.sourcesContent[0].length > 0
)
}) as SourceMapMeta[]

await Promise.all(entries.map(async ({ url, source, map, filepath }) => {
if (url in sourceMapMeta)
return

let code: string | undefined
try {
code = (await fs.readFile(filepath)).toString()
}
catch { }
const converter = v8ToIstanbul(url, WRAPPER_LENGTH, sources)
await converter.load()

// Vite does not report full path in sourcemap sources
// so use an actual file path
const sources = [url]

sourceMapMeta[url] = {
source,
map: {
sourcesContent: code ? [code] : undefined,
...map,
sources,
},
}
converter.applyCoverage(functions)
return converter.toIstanbul()
}))

// This is a magic number. It corresponds to the amount of code
// that we add in packages/vite-node/src/client.ts:114 (vm.runInThisContext)
// TODO: Include our transformations in sourcemaps
const offset = 185

report._getSourceMap = (coverage: Profiler.ScriptCoverage) => {
const path = _url.pathToFileURL(coverage.url.split('?')[0]).href
const data = sourceMapMeta[path]

if (!data)
return {}
const mergedCoverage = converted.reduce((coverage, previousCoverageMap) => {
const map = libCoverage.createCoverageMap(coverage)
map.merge(previousCoverageMap)
return map
}, libCoverage.createCoverageMap({}))

const sourceMapStore = libSourceMaps.createSourceMapStore()
const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage)

const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
sourceFinder: sourceMapStore.sourceFinder,
watermarks: this.options.watermarks,
})

for (const reporter of this.options.reporter) {
reports.create(reporter[0], {
skipFull: this.options.skipFull,
projectRoot: this.ctx.config.root,
...reporter[1],
}).execute(context)
}

return {
sourceMap: {
sourcemap: removeViteHelpersFromSourceMaps(data.source, data.map),
if (this.options.branches
|| this.options.functions
|| this.options.lines
|| this.options.statements) {
this.checkThresholds({
coverageMap,
thresholds: {
branches: this.options.branches,
functions: this.options.functions,
lines: this.options.lines,
statements: this.options.statements,
},
source: Array(offset).fill('.').join('') + data.source,
}
perFile: this.options.perFile,
})
}

await report.run()
await checkCoverages(options, report)

if (this.options.thresholdAutoUpdate && allTestsRun) {
this.updateThresholds({
coverageMap: await report.getCoverageMapFromAllCoverageFiles(),
coverageMap,
thresholds: {
branches: this.options.branches,
functions: this.options.functions,
Expand All @@ -202,6 +174,66 @@ export class C8CoverageProvider extends BaseCoverageProvider implements Coverage
})
}
}

private async getUntestedFiles(testedFiles: string[]): Promise<Profiler.ScriptCoverage[]> {
const includedFiles = await this.testExclude.glob(this.ctx.config.root)
const uncoveredFiles = includedFiles
.map(file => pathToFileURL(resolve(this.ctx.config.root, file)))
.filter(file => !testedFiles.includes(file.href))

return await Promise.all(uncoveredFiles.map(async (uncoveredFile) => {
const { source } = await this.getSources(uncoveredFile.href)

return {
url: uncoveredFile.href,
scriptId: '0',
// Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps.
functions: [{
ranges: [{
startOffset: 0,
// Wrapper length is required even for untested files istanbuljs/v8-to-istanbul#209
endOffset: WRAPPER_LENGTH + source.length,
count: 0,
}],
isBlockCoverage: true,
// This is magical value that indicates an empty report: https://github.com/istanbuljs/v8-to-istanbul/blob/fca5e6a9e6ef38a9cdc3a178d5a6cf9ef82e6cab/lib/v8-to-istanbul.js#LL131C40-L131C40
functionName: '(empty-report)',
}],
}
}))
}

private async getSources(url: string): Promise<{
source: string
originalSource?: string
sourceMap?: { sourcemap: EncodedSourceMap }
} | { source: string }> {
const filePath = normalize(fileURLToPath(url))
const transformResult = this.ctx.projects
.map(project => project.vitenode.fetchCache.get(filePath)?.result)
.filter(Boolean)
.shift()

const map = transformResult?.map
const code = transformResult?.code
const sourcesContent = map?.sourcesContent?.[0] || await fs.readFile(filePath, 'utf-8')

if (!map)
return { source: code || sourcesContent }

return {
originalSource: sourcesContent,
source: code || sourcesContent,
sourceMap: {
sourcemap: removeViteHelpersFromSourceMaps(code, {
...map,
version: 3,
sources: [url],
sourcesContent: [sourcesContent],
}),
},
}
}
}

/**
Expand All @@ -225,5 +257,5 @@ function removeViteHelpersFromSourceMaps(source: string | undefined, map: Encode
() => null,
)

return combinedMap
return combinedMap as EncodedSourceMap
}
Loading

0 comments on commit 91c096b

Please sign in to comment.