feat: embeded c8, close #321

This commit is contained in:
Anthony Fu 2021-12-27 00:14:06 +08:00
parent 7db9bcc86b
commit d0ce8d0cb4
13 changed files with 296 additions and 94 deletions

1
.gitignore vendored
View File

@ -79,3 +79,4 @@ dist
# IDE
.idea
.DS_Store

View File

@ -204,25 +204,26 @@ export default defineConfig({
Vitest supports Native code coverage via [c8](https://github.com/bcoe/c8)
```bash
$ npm i -D c8
$ c8 vitest
```
```json
{
"scripts": {
"test": "vitest",
"coverage": "c8 vitest"
"coverage": "vitest --coverage"
}
}
```
For convenience, we also provide a shorthand for passing `--coverage` option to CLI, which will wrap the process with `c8` for you. Note when using the shorthand, you will lose the ability to pass additional options to `c8`.
To configure it, set `test.coverage` options in your config file:
```bash
$ vitest --coverage
```ts
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html']
}
}
})
```
For more configuration available, please refer to [c8](https://github.com/bcoe/c8)'s documentation.

View File

@ -74,6 +74,7 @@
"chai-subset": "^1.6.0",
"cli-truncate": "^3.1.0",
"diff": "^5.0.0",
"execa": "^6.0.0",
"fast-glob": "^3.2.7",
"find-up": "^6.2.0",
"flatted": "^3.2.4",

View File

@ -0,0 +1,169 @@
import { existsSync, promises as fs } from 'fs'
import { createRequire } from 'module'
import { pathToFileURL } from 'url'
import { resolve } from 'pathe'
import type { Arrayable } from 'vitest'
import type { Vitest } from './node'
const defaultExcludes = [
'coverage/**',
'packages/*/test{,s}/**',
'**/*.d.ts',
'test{,s}/**',
'test{,-*}.{js,cjs,mjs,ts,tsx,jsx}',
'**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx}',
'**/__tests__/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc}.config.{js,cjs,mjs,ts}',
'**/.{eslint,mocha}rc.{js,cjs}',
]
export type Reporter =
| 'clover'
| 'cobertura'
| 'html-spa'
| 'html'
| 'json-summary'
| 'json'
| 'lcov'
| 'lcovonly'
| 'none'
| 'teamcity'
| 'text-lcov'
| 'text-summary'
| 'text'
export interface C8Options {
/**
* Enable coverage, pass `--coverage` to enable
*
* @default false
*/
enabled?: boolean
/**
* Directory to write coverage report to
*/
reportsDirectory?: string
/**
* Clean coverage before running tests
*
* @default true
*/
clean?: boolean
/**
* Clean coverage report on watch rerun
*
* @default false
*/
cleanOnRerun?: boolean
/**
* Allow files from outside of your cwd.
*
* @default false
*/
allowExternal?: any
/**
* Reporters
*
* @default 'text'
*/
reporter?: Arrayable<Reporter>
/**
* Exclude coverage under /node_modules/
*
* @default true
*/
excludeNodeModules?: boolean
exclude?: string[]
include?: string[]
skipFull?: boolean
// c8 options, not sure if we should expose them
/**
* Directory to store V8 coverage reports
*/
// tempDirectory?: string
// watermarks?: any
// excludeAfterRemap?: any
// omitRelative?: any
// wrapperLength?: any
// resolve?: any
// all?: any
// src?: any
}
export interface ResolvedC8Options extends Required<C8Options> {
tempDirectory: string
}
export function resolveC8Options(options: C8Options, root: string): ResolvedC8Options {
const resolved: ResolvedC8Options = {
enabled: false,
clean: true,
cleanOnRerun: false,
reportsDirectory: './coverage',
excludeNodeModules: true,
exclude: defaultExcludes,
reporter: 'text',
allowExternal: false,
...options as any,
}
resolved.reportsDirectory = resolve(root, resolved.reportsDirectory)
resolved.tempDirectory = process.env.NODE_V8_COVERAGE || resolve(resolved.reportsDirectory, 'tmp')
return resolved as ResolvedC8Options
}
export async function cleanCoverage(options: ResolvedC8Options, clean = true) {
if (clean && existsSync(options.reportsDirectory))
await fs.rmdir(options.reportsDirectory, { recursive: true })
if (!existsSync(options.tempDirectory))
await fs.mkdir(options.tempDirectory, { recursive: true })
}
export async function prepareCoverage(options: ResolvedC8Options) {
if (options.enabled)
return false
await cleanCoverage(options, options.clean)
}
const require = createRequire(import.meta.url)
export async function reportCoverage(ctx: Vitest) {
// await writeC8Sourcemap(ctx)
// eslint-disable-next-line @typescript-eslint/no-var-requires
const createReport = require('c8/lib/report')
const report = createReport(ctx.config.coverage)
const files = Array.from(ctx.visitedFilesMap.entries()).filter(i => !i[0].includes('/node_modules/'))
files.forEach(([file, map]) => {
if (!existsSync(file))
return
const url = pathToFileURL(file).href
report.sourceMapCache[url] = {
data: {
...map,
sources: map.sources.map(i => pathToFileURL(i).href) || [url],
},
}
})
await report.run()
}
// export async function writeC8Sourcemap(ctx: Vitest) {
// const cache: Record<string, any> = {}
// // write a fake coverage report with source map for c8 to consume
// await fs.writeFile(
// join(ctx.config.coverage.tempDirectory, 'vitest-source-map.json'),
// JSON.stringify({
// 'result': [],
// 'timestamp': performance.now(),
// 'source-map-cache': cache,
// }), 'utf-8',
// )
// }

View File

@ -22,10 +22,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { isObject } from '../../utils'
import type { Tester } from './types'
export const isObject = (val: any): val is object => toString.call(val) === '[object Object]'
// Extracted out of jasmine 2.5.2
export function equals(
a: unknown,

View File

@ -1,5 +1,6 @@
import readline from 'readline'
import cac from 'cac'
import { execa } from 'execa'
import type { UserConfig } from '../types'
import { version } from '../../package.json'
import { ensurePackageInstalled } from '../utils'
@ -47,7 +48,7 @@ cli.parse()
async function dev(cliFilters: string[], argv: UserConfig) {
if (argv.watch == null)
argv.watch = !process.env.CI && !process.env.NODE_V8_COVERAGE && !argv.run
argv.watch = !process.env.CI && !argv.run
await run(cliFilters, argv)
}
@ -55,17 +56,35 @@ async function run(cliFilters: string[], options: UserConfig) {
process.env.VITEST = 'true'
process.env.NODE_ENV = 'test'
if (!await ensurePackageInstalled('vite'))
process.exit(1)
if (typeof options.coverage === 'boolean')
options.coverage = { enabled: options.coverage }
const ctx = await createVitest(options)
process.chdir(ctx.config.root)
if (ctx.config.coverage.enabled) {
if (!await ensurePackageInstalled('c8'))
process.exit(1)
registerConsoleShortcuts(ctx)
if (!process.env.NODE_V8_COVERAGE) {
process.env.NODE_V8_COVERAGE = ctx.config.coverage.tempDirectory
const { exitCode } = await execa(process.argv0, process.argv.slice(1), { stdio: 'inherit' })
process.exit(exitCode)
}
}
if (ctx.config.environment && ctx.config.environment !== 'node') {
if (!await ensurePackageInstalled(ctx.config.environment))
process.exit(1)
}
if (process.stdin.isTTY && ctx.config.watch)
registerConsoleShortcuts(ctx)
process.chdir(ctx.config.root)
ctx.onServerRestarted(() => {
// TODO: re-consider how to re-run the tests the server smartly
ctx.start(cliFilters)
@ -88,23 +107,20 @@ async function run(cliFilters: string[], options: UserConfig) {
}
function registerConsoleShortcuts(ctx: Vitest) {
// listen to keyboard input
if (process.stdin.isTTY) {
readline.emitKeypressEvents(process.stdin)
process.stdin.setRawMode(true)
process.stdin.on('keypress', (str: string, key: any) => {
if (str === '\x03' || str === '\x1B' || (key && key.ctrl && key.name === 'c')) // ctrl-c or esc
process.exit()
readline.emitKeypressEvents(process.stdin)
process.stdin.setRawMode(true)
process.stdin.on('keypress', (str: string, key: any) => {
if (str === '\x03' || str === '\x1B' || (key && key.ctrl && key.name === 'c')) // ctrl-c or esc
process.exit()
// is running, ignore keypress
if (ctx.runningPromise)
return
// is running, ignore keypress
if (ctx.runningPromise)
return
// press any key to exit on first run
if (ctx.isFirstRun)
process.exit()
// press any key to exit on first run
if (ctx.isFirstRun)
process.exit()
// TODO: add more commands
})
}
// TODO: add more commands
})
}

View File

@ -2,6 +2,8 @@ import { resolve } from 'pathe'
import type { ResolvedConfig as ResolvedViteConfig } from 'vite'
import type { ResolvedConfig, UserConfig } from '../types'
import { defaultExclude, defaultInclude, defaultPort } from '../constants'
import { resolveC8Options } from '../coverage'
import { deepMerge } from '../utils'
export function resolveConfig(
options: UserConfig,
@ -11,11 +13,12 @@ export function resolveConfig(
options.environment = 'happy-dom'
const resolved = {
...options,
...viteConfig.test,
...deepMerge(options, viteConfig.test),
root: viteConfig.root,
} as ResolvedConfig
resolved.coverage = resolveC8Options(resolved.coverage, resolved.root)
resolved.depsInline = [...resolved.deps?.inline || []]
resolved.depsExternal = [...resolved.deps?.external || []]

View File

@ -1,6 +1,4 @@
import { existsSync, promises as fs } from 'fs'
import { pathToFileURL } from 'url'
import { join, resolve } from 'pathe'
import { resolve } from 'pathe'
import type { ViteDevServer, InlineConfig as ViteInlineConfig, Plugin as VitePlugin, UserConfig as ViteUserConfig } from 'vite'
import { createServer, mergeConfig } from 'vite'
import { findUp } from 'find-up'
@ -15,6 +13,7 @@ import { hasFailed, noop, slash, toArray } from '../utils'
import { MocksPlugin } from '../plugins/mock'
import { DefaultReporter } from '../reporters/default'
import { ReportersMap } from '../reporters'
import { cleanCoverage, prepareCoverage, reportCoverage } from '../coverage'
import type { WorkerPool } from './pool'
import { StateManager } from './state'
import { resolveConfig } from './config'
@ -45,7 +44,7 @@ class Vitest {
this.console = globalThis.console
}
setServer(options: UserConfig, server: ViteDevServer) {
async setServer(options: UserConfig, server: ViteDevServer) {
this.unregisterWatcher?.()
clearTimeout(this._rerunTimer)
this.restartsCount += 1
@ -53,6 +52,7 @@ class Vitest {
this.pool = undefined
const resolved = resolveConfig(options, server.config)
this.server = server
this.config = resolved
this.state = new StateManager()
@ -78,6 +78,9 @@ class Vitest {
this.runningPromise = undefined
this._onRestartListeners.forEach(fn => fn())
if (resolved.coverage.enabled)
await prepareCoverage(resolved.coverage)
}
async start(filters?: string[]) {
@ -96,7 +99,8 @@ class Vitest {
if (this.config.watch)
await this.report('onWatcherStart')
await this.writeC8Sourcemap()
if (this.config.coverage.enabled)
await reportCoverage(this)
}
async runFiles(files: string[]) {
@ -165,11 +169,18 @@ class Vitest {
const files = Array.from(this.changedTests)
this.changedTests.clear()
this.console.log('return')
if (this.config.coverage.enabled && this.config.coverage.cleanOnRerun)
await cleanCoverage(this.config.coverage)
await this.report('onWatcherRerun', files, triggerId)
await this.runFiles(files)
await this.report('onWatcherStart')
if (this.config.coverage.enabled)
await reportCoverage(this)
}, WATCHER_DEBOUNCE)
}
@ -259,38 +270,6 @@ class Vitest {
return files
}
async writeC8Sourcemap() {
const coverageDir = process.env.NODE_V8_COVERAGE
if (!coverageDir)
return
const cache: Record<string, any> = {}
const files = Array.from(this.visitedFilesMap.entries()).filter(i => !i[0].includes('/node_modules/'))
files.forEach(([file, map]) => {
if (!existsSync(file))
return
const url = pathToFileURL(file).href
cache[url] = {
data: {
...map,
sources: map.sources.map(i => pathToFileURL(i).href) || [url],
},
}
})
// write a fake coverage report with source map for c8 to consume
await fs.writeFile(
join(coverageDir, 'vitest-source-map.json'),
JSON.stringify({
'result': [],
'timestamp': performance.now(),
'source-map-cache': cache,
}), 'utf-8',
)
}
isTargetFile(id: string): boolean {
if (mm.isMatch(id, this.config.exclude))
return false
@ -326,7 +305,7 @@ export async function createVitest(options: UserConfig, viteOverrides: ViteUserC
async configureServer(server) {
if (haveStarted)
await ctx.report('onServerRestart')
ctx.setServer(options, server)
await ctx.setServer(options, server)
haveStarted = true
if (options.api)
server.middlewares.use((await import('../api/middleware')).default(ctx))

View File

@ -1,4 +1,5 @@
import type { BuiltinReporters } from '../reporters'
import type { C8Options, ResolvedC8Options } from '../coverage'
import type { Reporter } from './reporter'
import type { SnapshotStateOptions } from './snapshot'
import type { Arrayable } from './general'
@ -148,6 +149,11 @@ export interface InlineConfig {
*/
isolate?: boolean
/**
* Coverage options
*/
coverage?: C8Options
/**
* Open Vitest UI
* @internal WIP
@ -211,12 +217,13 @@ export interface UserConfig extends InlineConfig {
passWithNoTests?: boolean
}
export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'filters'> {
export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'filters' | 'coverage'> {
config?: string
filters?: string[]
depsInline: (string | RegExp)[]
depsExternal: (string | RegExp)[]
coverage: ResolvedC8Options
snapshotOptions: SnapshotStateOptions
}

View File

@ -140,6 +140,46 @@ export async function ensurePackageInstalled(
return false
}
/**
* Deep merge :P
*/
export function deepMerge<T extends object = object>(target: T, ...sources: any[]): T {
if (!sources.length)
return target as any
const source = sources.shift()
if (source === undefined)
return target as any
if (isMergableObject(target) && isMergableObject(source)) {
Object.keys(source).forEach((key) => {
if (isMergableObject(source[key])) {
// @ts-expect-error
if (!target[key])
// @ts-expect-error
target[key] = {}
// @ts-expect-error
deepMerge(target[key], source[key])
}
else {
// @ts-expect-error
target[key] = source[key]
}
})
}
return deepMerge(target, ...sources)
}
function isMergableObject(item: any): item is Object {
return isObject(item) && !Array.isArray(item)
}
export function isObject(val: any): val is object {
return toString.call(val) === '[object Object]'
}
export function toFilePath(id: string, root: string): string {
let absolute = slash(id).startsWith('/@fs/')
? id.slice(4)

View File

@ -1,21 +1,2 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url'
import { ensurePackageInstalled, resolvePath } from './dist/utils.js'
const argv = process.argv.slice(2)
if (!await ensurePackageInstalled('vite'))
process.exit(1)
if (argv.includes('--coverage')) {
if (!await ensurePackageInstalled('c8'))
process.exit(1)
const filename = fileURLToPath(import.meta.url)
const entry = resolvePath(filename, '../dist/cli.js')
process.argv.splice(2, 0, process.argv[0], entry)
await import('c8/bin/c8.js')
}
else {
await import('./dist/cli.js')
}
import('./dist/cli.js')

2
pnpm-lock.yaml generated
View File

@ -138,6 +138,7 @@ importers:
chai-subset: ^1.6.0
cli-truncate: ^3.1.0
diff: ^5.0.0
execa: ^6.0.0
fast-glob: ^3.2.7
find-up: ^6.2.0
flatted: ^3.2.4
@ -183,6 +184,7 @@ importers:
chai-subset: 1.6.0
cli-truncate: 3.1.0
diff: 5.0.0
execa: 6.0.0
fast-glob: 3.2.7
find-up: 6.2.0
flatted: 3.2.4

View File

@ -7,5 +7,8 @@ export default defineConfig({
setupFiles: [
'./test/setup.ts',
],
coverage: {
reporter: ['text', 'html'],
},
},
})