Skip to content

Commit

Permalink
feat: support monocart report
Browse files Browse the repository at this point in the history
  • Loading branch information
cenfun committed Mar 1, 2024
1 parent bf3073b commit e58292a
Show file tree
Hide file tree
Showing 8 changed files with 760 additions and 12 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Here is a list of common options. Run `c8 --help` for the full list and document
| `--per-file` | check thresholds per file | `boolean` | `false` |
| `--temp-directory` | directory V8 coverage data is written to and read from | `string` | `process.env.NODE_V8_COVERAGE` |
| `--clean` | should temp files be deleted before script execution | `boolean` | `true` |
| `--experimental-monocart` | see [section below](#using-monocart-coverage-reports-experimental) for more info | `boolean` | `false` |

## Checking for "full" source coverage using `--all`

Expand Down Expand Up @@ -119,6 +120,12 @@ The `--100` flag can be set for the `check-coverage` as well:
c8 check-coverage --100
```

## Using Monocart coverage reports (experimental)
[Monocart](https://github.com/cenfun/monocart-coverage-reports) will bring additional support for native V8 coverage reports, for example:
```sh
c8 --experimental-monocart --reporter=v8 --reporter=console-details node foo.js
```

## Ignoring Uncovered Lines, Functions, and Blocks

Sometimes you might find yourself wanting to ignore uncovered portions of your
Expand Down
7 changes: 7 additions & 0 deletions lib/commands/report.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { checkCoverages } = require('./check-coverage')
const Report = require('../report')
const monocartReport = require('../monocart-report')

exports.command = 'report'

Expand All @@ -18,6 +19,12 @@ exports.outputReport = async function (argv) {
argv.branches = 100
argv.statements = 100
}

if (argv.experimentalMonocart || process.env.EXPERIMENTAL_MONOCART) {
await monocartReport(argv)
return
}

const report = Report({
include: argv.include,
exclude: argv.exclude,
Expand Down
264 changes: 264 additions & 0 deletions lib/monocart-report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
const Exclude = require('test-exclude')
const fs = require('fs')
const path = require('path')
const { fileURLToPath } = require('url')

const { CoverageReport } = require('monocart-coverage-reports')

module.exports = async (argv) => {
// console.log(argv);
const exclude = new Exclude({
exclude: argv.exclude,
include: argv.include,
extension: argv.extension,
relativePath: !argv.allowExternal,
excludeNodeModules: argv.excludeNodeModules
})

// adapt coverage options
const coverageOptions = getCoverageOptions(argv, exclude)
const coverageReport = new CoverageReport(coverageOptions)
coverageReport.cleanCache()

// read v8 coverage data from tempDirectory
await addV8Coverage(coverageReport, argv)

// generate report
await coverageReport.generate()
}

function getReports (argv) {
const reports = Array.isArray(argv.reporter) ? argv.reporter : [argv.reporter]
const reporterOptions = argv.reporterOptions || {}

return reports.map((reportName) => {
const reportOptions = {
...reporterOptions[reportName]
}
if (reportName === 'text') {
reportOptions.skipEmpty = false
reportOptions.skipFull = argv.skipFull
reportOptions.maxCols = process.stdout.columns || 100
}
return [reportName, reportOptions]
})
}

// --all: add empty coverage for all files
function getAllOptions (argv, exclude) {
if (!argv.all) {
return
}

const src = argv.src
const workingDirs = Array.isArray(src) ? src : (typeof src === 'string' ? [src] : [process.cwd()])
return {
dir: workingDirs,
filter: (filePath) => {
return exclude.shouldInstrument(filePath)
}
}
}

function getEntryFilter (argv, exclude) {
if (argv.entryFilter) {
return argv.entryFilter
}
return (entry) => {
return exclude.shouldInstrument(fileURLToPath(entry.url))
}
}

function getSourceFilter (argv, exclude) {
if (argv.sourceFilter) {
return argv.sourceFilter
}
return (sourcePath) => {
if (argv.excludeAfterRemap) {
// console.log(sourcePath)
return exclude.shouldInstrument(sourcePath)
}
return true
}
}

function getCoverageOptions (argv, exclude) {
const reports = getReports(argv)
const allOptions = getAllOptions(argv, exclude)

return {
logging: argv.logging,
name: argv.name,
inline: argv.inline,
lcov: argv.lcov,
outputDir: argv.reportsDir,
clean: argv.clean,

reports,
all: allOptions,

// use default value for istanbul
defaultSummarizer: 'pkg',

entryFilter: getEntryFilter(argv, exclude),

sourceFilter: getSourceFilter(argv, exclude),

// sourcePath: (filePath) => {
// return path.resolve(filePath);
// },

onEnd: (coverageResults) => {
// console.log(`Coverage report generated: ${coverageResults.reportPath}`);

if (!argv.checkCoverage) {
return
}

// check thresholds
const thresholds = {}
const metrics = ['bytes', 'statements', 'branches', 'functions', 'lines']
metrics.forEach((k) => {
if (argv[k]) {
thresholds[k] = argv[k]
}
})

const { summary, files } = coverageResults

if (argv.perFile) {
files.forEach((file) => {
checkCoverage(file.summary, thresholds, file)
})
} else {
checkCoverage(summary, thresholds)
}
}
}
}

function checkCoverage (summary, thresholds, file) {
if (file && file.empty) {
process.exitCode = 1
console.error(
'ERROR: Empty coverage (untested file) does not meet threshold for ' +
path.relative('./', file.sourcePath).replace(/\\/g, '/')
)
return
}
Object.keys(thresholds).forEach(key => {
const coverage = summary[key].pct
if (typeof coverage !== 'number') {
return
}
if (coverage < thresholds[key]) {
process.exitCode = 1
if (file) {
console.error(
'ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet threshold (' + thresholds[key] + '%) for ' +
path.relative('./', file.sourcePath).replace(/\\/g, '/') // standardize path for Windows.
)
} else {
console.error('ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet global threshold (' + thresholds[key] + '%)')
}
}
})
}

function getFileSource (filePath) {
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath).toString('utf8')
}
return ''
}

const resolveSourceMap = (sourceMap, url) => {
if (!sourceMap.sourcesContent) {
sourceMap.sourcesContent = sourceMap.sources.map(fileUrl => {
return getFileSource(fileURLToPath(fileUrl))
})
}
return sourceMap
}

const resolveEntrySource = (entry, sourceMapCache = {}) => {
let source
const filePath = fileURLToPath(entry.url)
const extname = path.extname(filePath)
if (fs.existsSync(filePath)) {
source = fs.readFileSync(filePath).toString('utf8')
}

// not for typescript
if (source && !['.ts', '.tsx'].includes(extname)) {
entry.source = source
return
}

const sourcemapData = sourceMapCache[entry.url]
const lineLengths = sourcemapData && sourcemapData.lineLengths

// for fake source file (can not parse to AST)
if (lineLengths) {
// get runtime code with ts-node
let fakeSource = ''
sourcemapData.lineLengths.forEach((length) => {
fakeSource += `${''.padEnd(length, '*')}\n`
})
entry.fake = true
entry.source = fakeSource
}

// Note: no runtime code in source map cache
// This is a problem for typescript
}

const resolveEntrySourceMap = (entry, sourceMapCache = {}) => {
// sourcemap data
const sourcemapData = sourceMapCache[entry.url]
if (sourcemapData) {
if (sourcemapData.data) {
entry.sourceMap = resolveSourceMap(sourcemapData.data, entry.url)
}
}
}

const collectCoverageData = (coverageList, entryFilterHandler, sourceMapCache = {}) => {
if (!coverageList.length) {
return
}

// filter node internal files
coverageList = coverageList.filter((entry) => entry.url && entry.url.startsWith('file:'))
coverageList = coverageList.filter(entryFilterHandler)

if (!coverageList.length) {
return
}

for (const entry of coverageList) {
resolveEntrySource(entry, sourceMapCache)
resolveEntrySourceMap(entry, sourceMapCache)
}

return coverageList
}

async function addV8Coverage (coverageReport, argv) {
const entryFilterHandler = coverageReport.getEntryFilter()
const dir = argv.tempDirectory
const files = fs.readdirSync(dir)
for (const filename of files) {
const content = fs.readFileSync(path.resolve(dir, filename)).toString('utf-8')
if (!content) {
continue
}
const json = JSON.parse(content)
const coverageList = json.result
const sourceMapCache = json['source-map-cache']
const coverageData = await collectCoverageData(coverageList, entryFilterHandler, sourceMapCache)
if (coverageData) {
await coverageReport.add(coverageData)
}
}
}
5 changes: 5 additions & 0 deletions lib/parse-args.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ function buildYargs (withCommands = false) {
describe: 'supplying --merge-async will merge all v8 coverage reports asynchronously and incrementally. ' +
'This is to avoid OOM issues with Node.js runtime.'
})
.option('experimental-monocart', {
default: false,
type: 'boolean',
describe: 'Using Monocart coverage reports'
})
.pkgConf('c8')
.demandCommand(1)
.check((argv) => {
Expand Down
Loading

0 comments on commit e58292a

Please sign in to comment.