|
| 1 | +import { existsSync, promises as fs } from 'node:fs' |
| 2 | +import type { Profiler } from 'node:inspector' |
| 3 | +import { fileURLToPath, pathToFileURL } from 'node:url' |
| 4 | +import v8ToIstanbul from 'v8-to-istanbul' |
| 5 | +import { mergeProcessCovs } from '@bcoe/v8-coverage' |
| 6 | +import libReport from 'istanbul-lib-report' |
| 7 | +import reports from 'istanbul-reports' |
| 8 | +import type { CoverageMap } from 'istanbul-lib-coverage' |
| 9 | +import libCoverage from 'istanbul-lib-coverage' |
| 10 | +import libSourceMaps from 'istanbul-lib-source-maps' |
| 11 | +import MagicString from 'magic-string' |
| 12 | +import remapping from '@ampproject/remapping' |
| 13 | +import { normalize, resolve } from 'pathe' |
| 14 | +import c from 'picocolors' |
| 15 | +import { provider } from 'std-env' |
| 16 | +import type { EncodedSourceMap } from 'vite-node' |
| 17 | +import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config' |
| 18 | +import { BaseCoverageProvider } from 'vitest/coverage' |
| 19 | +import type { AfterSuiteRunMeta, CoverageProvider, CoverageV8Options, ReportContext, ResolvedCoverageOptions } from 'vitest' |
| 20 | +import type { Vitest } from 'vitest/node' |
| 21 | + |
| 22 | +// @ts-expect-error missing types |
| 23 | +import _TestExclude from 'test-exclude' |
| 24 | + |
| 25 | +interface TestExclude { |
| 26 | + new(opts: { |
| 27 | + cwd?: string | string[] |
| 28 | + include?: string | string[] |
| 29 | + exclude?: string | string[] |
| 30 | + extension?: string | string[] |
| 31 | + excludeNodeModules?: boolean |
| 32 | + }): { |
| 33 | + shouldInstrument(filePath: string): boolean |
| 34 | + glob(cwd: string): Promise<string[]> |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +type Options = ResolvedCoverageOptions<'v8'> |
| 39 | + |
| 40 | +// TODO: vite-node should export this |
| 41 | +const WRAPPER_LENGTH = 185 |
| 42 | + |
| 43 | +// Note that this needs to match the line ending as well |
| 44 | +const VITE_EXPORTS_LINE_PATTERN = /Object\.defineProperty\(__vite_ssr_exports__.*\n/g |
| 45 | + |
| 46 | +export class V8CoverageProvider extends BaseCoverageProvider implements CoverageProvider { |
| 47 | + name = 'v8' |
| 48 | + |
| 49 | + ctx!: Vitest |
| 50 | + options!: Options |
| 51 | + testExclude!: InstanceType<TestExclude> |
| 52 | + coverages: Profiler.TakePreciseCoverageReturnType[] = [] |
| 53 | + |
| 54 | + initialize(ctx: Vitest) { |
| 55 | + const config: CoverageV8Options = ctx.config.coverage |
| 56 | + |
| 57 | + this.ctx = ctx |
| 58 | + this.options = { |
| 59 | + ...coverageConfigDefaults, |
| 60 | + |
| 61 | + // User's options |
| 62 | + ...config, |
| 63 | + |
| 64 | + // Resolved fields |
| 65 | + provider: 'v8', |
| 66 | + reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter), |
| 67 | + reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory), |
| 68 | + lines: config['100'] ? 100 : config.lines, |
| 69 | + functions: config['100'] ? 100 : config.functions, |
| 70 | + branches: config['100'] ? 100 : config.branches, |
| 71 | + statements: config['100'] ? 100 : config.statements, |
| 72 | + } |
| 73 | + |
| 74 | + this.testExclude = new _TestExclude({ |
| 75 | + cwd: ctx.config.root, |
| 76 | + include: typeof this.options.include === 'undefined' ? undefined : [...this.options.include], |
| 77 | + exclude: [...defaultExclude, ...defaultInclude, ...this.options.exclude], |
| 78 | + excludeNodeModules: true, |
| 79 | + extension: this.options.extension, |
| 80 | + }) |
| 81 | + } |
| 82 | + |
| 83 | + resolveOptions() { |
| 84 | + return this.options |
| 85 | + } |
| 86 | + |
| 87 | + async clean(clean = true) { |
| 88 | + if (clean && existsSync(this.options.reportsDirectory)) |
| 89 | + await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 }) |
| 90 | + |
| 91 | + this.coverages = [] |
| 92 | + } |
| 93 | + |
| 94 | + onAfterSuiteRun({ coverage }: AfterSuiteRunMeta) { |
| 95 | + this.coverages.push(coverage as Profiler.TakePreciseCoverageReturnType) |
| 96 | + } |
| 97 | + |
| 98 | + async reportCoverage({ allTestsRun }: ReportContext = {}) { |
| 99 | + if (provider === 'stackblitz') |
| 100 | + this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.')) |
| 101 | + |
| 102 | + const merged = mergeProcessCovs(this.coverages) |
| 103 | + const scriptCoverages = merged.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url))) |
| 104 | + |
| 105 | + if (this.options.all && allTestsRun) { |
| 106 | + const coveredFiles = Array.from(scriptCoverages.map(r => r.url)) |
| 107 | + const untestedFiles = await this.getUntestedFiles(coveredFiles) |
| 108 | + |
| 109 | + scriptCoverages.push(...untestedFiles) |
| 110 | + } |
| 111 | + |
| 112 | + const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => { |
| 113 | + const sources = await this.getSources(url) |
| 114 | + |
| 115 | + // If no source map was found from vite-node we can assume this file was not run in the wrapper |
| 116 | + const wrapperLength = sources.sourceMap ? WRAPPER_LENGTH : 0 |
| 117 | + |
| 118 | + const converter = v8ToIstanbul(url, wrapperLength, sources) |
| 119 | + await converter.load() |
| 120 | + |
| 121 | + converter.applyCoverage(functions) |
| 122 | + return converter.toIstanbul() |
| 123 | + })) |
| 124 | + |
| 125 | + const mergedCoverage = converted.reduce((coverage, previousCoverageMap) => { |
| 126 | + const map = libCoverage.createCoverageMap(coverage) |
| 127 | + map.merge(previousCoverageMap) |
| 128 | + return map |
| 129 | + }, libCoverage.createCoverageMap({})) |
| 130 | + |
| 131 | + const sourceMapStore = libSourceMaps.createSourceMapStore() |
| 132 | + const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage) |
| 133 | + |
| 134 | + const context = libReport.createContext({ |
| 135 | + dir: this.options.reportsDirectory, |
| 136 | + coverageMap, |
| 137 | + sourceFinder: sourceMapStore.sourceFinder, |
| 138 | + watermarks: this.options.watermarks, |
| 139 | + }) |
| 140 | + |
| 141 | + for (const reporter of this.options.reporter) { |
| 142 | + reports.create(reporter[0], { |
| 143 | + skipFull: this.options.skipFull, |
| 144 | + projectRoot: this.ctx.config.root, |
| 145 | + ...reporter[1], |
| 146 | + }).execute(context) |
| 147 | + } |
| 148 | + |
| 149 | + if (this.options.branches |
| 150 | + || this.options.functions |
| 151 | + || this.options.lines |
| 152 | + || this.options.statements) { |
| 153 | + this.checkThresholds({ |
| 154 | + coverageMap, |
| 155 | + thresholds: { |
| 156 | + branches: this.options.branches, |
| 157 | + functions: this.options.functions, |
| 158 | + lines: this.options.lines, |
| 159 | + statements: this.options.statements, |
| 160 | + }, |
| 161 | + perFile: this.options.perFile, |
| 162 | + }) |
| 163 | + } |
| 164 | + |
| 165 | + if (this.options.thresholdAutoUpdate && allTestsRun) { |
| 166 | + this.updateThresholds({ |
| 167 | + coverageMap, |
| 168 | + thresholds: { |
| 169 | + branches: this.options.branches, |
| 170 | + functions: this.options.functions, |
| 171 | + lines: this.options.lines, |
| 172 | + statements: this.options.statements, |
| 173 | + }, |
| 174 | + perFile: this.options.perFile, |
| 175 | + configurationFile: this.ctx.server.config.configFile, |
| 176 | + }) |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + private async getUntestedFiles(testedFiles: string[]): Promise<Profiler.ScriptCoverage[]> { |
| 181 | + const includedFiles = await this.testExclude.glob(this.ctx.config.root) |
| 182 | + const uncoveredFiles = includedFiles |
| 183 | + .map(file => pathToFileURL(resolve(this.ctx.config.root, file))) |
| 184 | + .filter(file => !testedFiles.includes(file.href)) |
| 185 | + |
| 186 | + return await Promise.all(uncoveredFiles.map(async (uncoveredFile) => { |
| 187 | + const { source } = await this.getSources(uncoveredFile.href) |
| 188 | + |
| 189 | + return { |
| 190 | + url: uncoveredFile.href, |
| 191 | + scriptId: '0', |
| 192 | + // Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps. |
| 193 | + functions: [{ |
| 194 | + ranges: [{ |
| 195 | + startOffset: 0, |
| 196 | + endOffset: source.length, |
| 197 | + count: 0, |
| 198 | + }], |
| 199 | + isBlockCoverage: true, |
| 200 | + // 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 |
| 201 | + functionName: '(empty-report)', |
| 202 | + }], |
| 203 | + } |
| 204 | + })) |
| 205 | + } |
| 206 | + |
| 207 | + private async getSources(url: string): Promise<{ |
| 208 | + source: string |
| 209 | + originalSource?: string |
| 210 | + sourceMap?: { sourcemap: EncodedSourceMap } |
| 211 | + }> { |
| 212 | + const filePath = normalize(fileURLToPath(url)) |
| 213 | + const transformResult = this.ctx.projects |
| 214 | + .map(project => project.vitenode.fetchCache.get(filePath)?.result) |
| 215 | + .filter(Boolean) |
| 216 | + .shift() |
| 217 | + |
| 218 | + const map = transformResult?.map |
| 219 | + const code = transformResult?.code |
| 220 | + const sourcesContent = map?.sourcesContent?.[0] || await fs.readFile(filePath, 'utf-8') |
| 221 | + |
| 222 | + // These can be uncovered files included by "all: true" or files that are loaded outside vite-node |
| 223 | + if (!map) |
| 224 | + return { source: code || sourcesContent } |
| 225 | + |
| 226 | + return { |
| 227 | + originalSource: sourcesContent, |
| 228 | + source: code || sourcesContent, |
| 229 | + sourceMap: { |
| 230 | + sourcemap: removeViteHelpersFromSourceMaps(code, { |
| 231 | + ...map, |
| 232 | + version: 3, |
| 233 | + sources: [url], |
| 234 | + sourcesContent: [sourcesContent], |
| 235 | + }), |
| 236 | + }, |
| 237 | + } |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +/** |
| 242 | + * Remove generated code from the source maps: |
| 243 | + * - Vite's export helpers: e.g. `Object.defineProperty(__vite_ssr_exports__, "sum", { enumerable: true, configurable: true, get(){ return sum }});` |
| 244 | + */ |
| 245 | +function removeViteHelpersFromSourceMaps(source: string | undefined, map: EncodedSourceMap) { |
| 246 | + if (!source || !source.match(VITE_EXPORTS_LINE_PATTERN)) |
| 247 | + return map |
| 248 | + |
| 249 | + const sourceWithoutHelpers = new MagicString(source) |
| 250 | + sourceWithoutHelpers.replaceAll(VITE_EXPORTS_LINE_PATTERN, '\n') |
| 251 | + |
| 252 | + const mapWithoutHelpers = sourceWithoutHelpers.generateMap({ |
| 253 | + hires: true, |
| 254 | + }) |
| 255 | + |
| 256 | + // A merged source map where the first one excludes helpers |
| 257 | + const combinedMap = remapping( |
| 258 | + [{ ...mapWithoutHelpers, version: 3 }, map], |
| 259 | + () => null, |
| 260 | + ) |
| 261 | + |
| 262 | + return combinedMap as EncodedSourceMap |
| 263 | +} |
0 commit comments