diff --git a/.circleci/config.yml b/.circleci/config.yml index 799407b9..e5e67f83 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -194,6 +194,30 @@ workflows: ../../node_modules/.bin/only-covered main.js working_directory: examples/before-all-visit + - cypress/run: + attach-workspace: true + name: example-docker-paths + requires: + - cypress/install + # there are no jobs to follow this one + # so no need to save the workspace files (saves time) + no-workspace: true + working_directory: examples/docker-paths + command: '../../node_modules/.bin/cypress run' + post-steps: + # store the created coverage report folder + # you can click on it in the CircleCI UI + # to see live static HTML site + - store_artifacts: + path: examples/docker-paths/coverage + - run: + name: Check code coverage 📈 + command: | + ../../node_modules/.bin/check-coverage main.js + ../../node_modules/.bin/check-coverage second.js + ../../node_modules/.bin/only-covered main.js second.js + working_directory: examples/docker-paths + - cypress/run: attach-workspace: true name: example-ts-example @@ -395,3 +419,4 @@ workflows: - example-use-plugins-and-support - example-one-spec - example-exclude-files + - example-docker-paths diff --git a/examples/docker-paths/README.md b/examples/docker-paths/README.md new file mode 100644 index 00000000..0e8c20d1 --- /dev/null +++ b/examples/docker-paths/README.md @@ -0,0 +1,38 @@ +# example-docker-paths + +In this example, the source files are "instrumented" as if they were instrumented inside a Docker container. Still, Cypress code coverage plugin should find the matching current folder where same files exist and update `.nyc_output/out.json` file before generating reports. + +Source files from `app` folder were instrumented into `dist` folder with command + +```shell +$ npx nyc instrument app dist +``` + +Then the `index.html` file was copied into `dist` folder. + +Then the source paths in [dist/main.js](dist/main.js) and [dist/second.js](dist/second.js) were changed to non-existent prefix folder `/var/www/test/site`. + +When Cypress runs, the `.nyc_output/out.json` is updated, so the path is valid local path like: + +``` +{ + "/var/www/test/site/app/main.js": { + "path": "/Users/gleb/git/code-coverage/examples/docker-paths/app/main.js", + "statementMap": { + ... +``` + +And the report has valid HTML with sources + +![All files](images/files.png) + +![Single file](images/file.png) + +**Note:** remember to remove existing `.nyc_output` folder if running Cypress in non-interactive mode `rm -rf .nyc_output/`. + +When running with [debug logs](https://github.com/cypress-io/code-coverage#debugging) you should see messages: + +``` +found common folder /var/www/test/site that matches +current working directory /Users/gleb/git/code-coverage/examples/docker-paths +``` diff --git a/examples/docker-paths/app/index.html b/examples/docker-paths/app/index.html new file mode 100644 index 00000000..993f0c18 --- /dev/null +++ b/examples/docker-paths/app/index.html @@ -0,0 +1,17 @@ + + Page body + + + + diff --git a/examples/docker-paths/app/main.js b/examples/docker-paths/app/main.js new file mode 100644 index 00000000..5dd69be2 --- /dev/null +++ b/examples/docker-paths/app/main.js @@ -0,0 +1,3 @@ +window.add = (a, b) => a + b + +window.sub = (a, b) => a - b diff --git a/examples/docker-paths/app/second.js b/examples/docker-paths/app/second.js new file mode 100644 index 00000000..494a0c5f --- /dev/null +++ b/examples/docker-paths/app/second.js @@ -0,0 +1,7 @@ +// this file should be excluded from the final coverage numbers +// using "nyc.exclude" list in package.json +window.reverse = s => + s + .split('') + .reverse() + .join('') diff --git a/examples/docker-paths/cypress.json b/examples/docker-paths/cypress.json new file mode 100644 index 00000000..3681940e --- /dev/null +++ b/examples/docker-paths/cypress.json @@ -0,0 +1,5 @@ +{ + "pluginsFile": "../../plugins", + "supportFile": "../../support", + "fixturesFolder": false +} diff --git a/examples/docker-paths/cypress/integration/spec.js b/examples/docker-paths/cypress/integration/spec.js new file mode 100644 index 00000000..3da439de --- /dev/null +++ b/examples/docker-paths/cypress/integration/spec.js @@ -0,0 +1,11 @@ +/// +describe('docker-paths', () => { + it('works', () => { + cy.visit('dist/index.html') + cy.contains('Page body') + + cy.window() + .invoke('reverse', 'super') + .should('equal', 'repus') + }) +}) diff --git a/examples/docker-paths/dist/index.html b/examples/docker-paths/dist/index.html new file mode 100644 index 00000000..993f0c18 --- /dev/null +++ b/examples/docker-paths/dist/index.html @@ -0,0 +1,17 @@ + + Page body + + + + diff --git a/examples/docker-paths/dist/main.js b/examples/docker-paths/dist/main.js new file mode 100644 index 00000000..9bb7cd41 --- /dev/null +++ b/examples/docker-paths/dist/main.js @@ -0,0 +1,58 @@ +function cov_25n278q6qe() { + var path = '/var/www/test/site/app/main.js' + var hash = '8817d7301264befa6a587eeb9305a1bf2cbde8bc' + var global = new Function('return this')() + var gcv = '__coverage__' + var coverageData = { + path: '/var/www/test/site/app/main.js', + statementMap: { + '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 28 } }, + '1': { start: { line: 1, column: 23 }, end: { line: 1, column: 28 } }, + '2': { start: { line: 3, column: 0 }, end: { line: 3, column: 28 } }, + '3': { start: { line: 3, column: 23 }, end: { line: 3, column: 28 } } + }, + fnMap: { + '0': { + name: '(anonymous_0)', + decl: { start: { line: 1, column: 13 }, end: { line: 1, column: 14 } }, + loc: { start: { line: 1, column: 23 }, end: { line: 1, column: 28 } }, + line: 1 + }, + '1': { + name: '(anonymous_1)', + decl: { start: { line: 3, column: 13 }, end: { line: 3, column: 14 } }, + loc: { start: { line: 3, column: 23 }, end: { line: 3, column: 28 } }, + line: 3 + } + }, + branchMap: {}, + s: { '0': 0, '1': 0, '2': 0, '3': 0 }, + f: { '0': 0, '1': 0 }, + b: {}, + _coverageSchema: '1a1c01bbd47fc00a2c39e90264f33305004495a9', + hash: '8817d7301264befa6a587eeb9305a1bf2cbde8bc' + } + var coverage = global[gcv] || (global[gcv] = {}) + if (!coverage[path] || coverage[path].hash !== hash) { + coverage[path] = coverageData + } + var actualCoverage = coverage[path] + cov_25n278q6qe = function() { + return actualCoverage + } + return actualCoverage +} +cov_25n278q6qe() +cov_25n278q6qe().s[0]++ +window.add = (a, b) => { + cov_25n278q6qe().f[0]++ + cov_25n278q6qe().s[1]++ + return a + b +} +cov_25n278q6qe().s[2]++ +window.sub = (a, b) => { + cov_25n278q6qe().f[1]++ + cov_25n278q6qe().s[3]++ + return a - b +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIm1haW4uanMiXSwibmFtZXMiOlsid2luZG93IiwiYWRkIiwiYSIsImIiLCJzdWIiXSwibWFwcGluZ3MiOiJtdkNBQUFBLE1BQU0sQ0FBQ0MsR0FBUCxDQUFhLENBQUNDLENBQUQsQ0FBSUMsQ0FBSixHQUFVLHVEQUFBRCxDQUFBQSxDQUFDLENBQUdDLENBQUosQ0FBSyxDQUE1QixDLHdCQUVBSCxNQUFNLENBQUNJLEdBQVAsQ0FBYSxDQUFDRixDQUFELENBQUlDLENBQUosR0FBVSx1REFBQUQsQ0FBQUEsQ0FBQyxDQUFHQyxDQUFKLENBQUssQ0FBNUIiLCJzb3VyY2VzQ29udGVudCI6WyJ3aW5kb3cuYWRkID0gKGEsIGIpID0+IGEgKyBiXG5cbndpbmRvdy5zdWIgPSAoYSwgYikgPT4gYSAtIGJcbiJdfQ== diff --git a/examples/docker-paths/dist/second.js b/examples/docker-paths/dist/second.js new file mode 100644 index 00000000..d5dfce4c --- /dev/null +++ b/examples/docker-paths/dist/second.js @@ -0,0 +1,48 @@ +function cov_oh5ama61f() { + var path = '/var/www/test/site/app/second.js' + var hash = 'b86d13c912cff987dc7ed851327509c7a170ff4b' + var global = new Function('return this')() + var gcv = '__coverage__' + var coverageData = { + path: '/var/www/test/site/app/second.js', + statementMap: { + '0': { start: { line: 3, column: 0 }, end: { line: 7, column: 13 } }, + '1': { start: { line: 4, column: 2 }, end: { line: 7, column: 13 } } + }, + fnMap: { + '0': { + name: '(anonymous_0)', + decl: { start: { line: 3, column: 17 }, end: { line: 3, column: 18 } }, + loc: { start: { line: 4, column: 2 }, end: { line: 7, column: 13 } }, + line: 4 + } + }, + branchMap: {}, + s: { '0': 0, '1': 0 }, + f: { '0': 0 }, + b: {}, + _coverageSchema: '1a1c01bbd47fc00a2c39e90264f33305004495a9', + hash: 'b86d13c912cff987dc7ed851327509c7a170ff4b' + } + var coverage = global[gcv] || (global[gcv] = {}) + if (!coverage[path] || coverage[path].hash !== hash) { + coverage[path] = coverageData + } + var actualCoverage = coverage[path] + cov_oh5ama61f = function() { + return actualCoverage + } + return actualCoverage +} +cov_oh5ama61f() +cov_oh5ama61f().s[0]++ // this file should be excluded from the final coverage numbers +// using "nyc.exclude" list in package.json +window.reverse = s => { + cov_oh5ama61f().f[0]++ + cov_oh5ama61f().s[1]++ + return s + .split('') + .reverse() + .join('') +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNlY29uZC5qcyJdLCJuYW1lcyI6WyJ3aW5kb3ciLCJyZXZlcnNlIiwicyIsInNwbGl0Iiwiam9pbiJdLCJtYXBwaW5ncyI6InErQkFBQTtBQUNBO0FBQ0FBLE1BQU0sQ0FBQ0MsT0FBUCxDQUFpQkMsQ0FBQyxFQUNoQixxREFBQUEsQ0FBQUEsQ0FBQyxDQUNFQyxLQURILENBQ1MsRUFEVCxFQUVHRixPQUZILEdBR0dHLElBSEgsQ0FHUSxFQUhSLEVBR1csQ0FKYiIsInNvdXJjZXNDb250ZW50IjpbIi8vIHRoaXMgZmlsZSBzaG91bGQgYmUgZXhjbHVkZWQgZnJvbSB0aGUgZmluYWwgY292ZXJhZ2UgbnVtYmVyc1xuLy8gdXNpbmcgXCJueWMuZXhjbHVkZVwiIGxpc3QgaW4gcGFja2FnZS5qc29uXG53aW5kb3cucmV2ZXJzZSA9IHMgPT5cbiAgc1xuICAgIC5zcGxpdCgnJylcbiAgICAucmV2ZXJzZSgpXG4gICAgLmpvaW4oJycpXG4iXX0= diff --git a/examples/docker-paths/images/file.png b/examples/docker-paths/images/file.png new file mode 100644 index 00000000..8a050cb4 Binary files /dev/null and b/examples/docker-paths/images/file.png differ diff --git a/examples/docker-paths/images/files.png b/examples/docker-paths/images/files.png new file mode 100644 index 00000000..29be1b4f Binary files /dev/null and b/examples/docker-paths/images/files.png differ diff --git a/examples/docker-paths/package.json b/examples/docker-paths/package.json new file mode 100644 index 00000000..72104d30 --- /dev/null +++ b/examples/docker-paths/package.json @@ -0,0 +1,14 @@ +{ + "name": "example-docker-paths", + "private": true, + "version": "1.0.0", + "description": "Instrumented files in Docker container are found locally", + "main": "index.js", + "scripts": { + "cy:open": "../../node_modules/.bin/cypress open", + "cy:run": "../../node_modules/.bin/cypress run" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/package.json b/package.json index ad715ebe..68d98088 100644 --- a/package.json +++ b/package.json @@ -66,5 +66,8 @@ "serve": "11.3.0", "start-server-and-test": "1.10.11", "typescript": "3.8.3" + }, + "nyc": { + "exclude": ["utils.js"] } } diff --git a/task.js b/task.js index c48f5e65..8d9135ff 100644 --- a/task.js +++ b/task.js @@ -1,10 +1,15 @@ // @ts-check const istanbul = require('istanbul-lib-coverage') -const { join, resolve, isAbsolute } = require('path') +const { join, resolve } = require('path') const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs') const execa = require('execa') -const fs = require('fs') -const { fixSourcePathes } = require('./utils') +const { + fixSourcePathes, + showNycInfo, + resolveRelativePaths, + checkAllPathsNotFound, + tryFindingLocalFiles +} = require('./utils') const NYC = require('nyc') const debug = require('debug')('code-coverage') @@ -20,8 +25,8 @@ const nycFilename = join(coverageFolder, 'out.json') // potentially there might be "nyc" options in other configuration files // it allows, but for now ignore those options const pkgFilename = join(processWorkingDirectory, 'package.json') -const pkg = fs.existsSync(pkgFilename) - ? JSON.parse(fs.readFileSync(pkgFilename, 'utf8')) +const pkg = existsSync(pkgFilename) + ? JSON.parse(readFileSync(pkgFilename, 'utf8')) : {} const nycOptions = pkg.nyc || {} const scripts = pkg.scripts || {} @@ -37,52 +42,6 @@ function saveCoverage(coverage) { writeFileSync(nycFilename, JSON.stringify(coverage, null, 2)) } -/** - * Looks at all coverage objects in the given JSON coverage file - * and if the file is relative, and exists, changes its path to - * be absolute. - */ -function resolvePaths(nycFilename) { - const nycCoverage = JSON.parse(readFileSync(nycFilename, 'utf8')) - - const coverageKeys = Object.keys(nycCoverage) - if (!coverageKeys.length) { - console.error('⚠️ file %s has no coverage information', nycFilename) - return - } - debug('NYC file %s has %d key(s)', nycFilename, coverageKeys.length) - - let changed - const maxPrintKeys = 3 - - Object.keys(nycCoverage).forEach((key, k) => { - const coverage = nycCoverage[key] - - // printing a few found keys and file paths from the coverage file - // will make debugging any problems much much easier - if (k < maxPrintKeys) { - debug('%d key %s file path %s', k + 1, key, coverage.path) - } - - if (coverage.path && !isAbsolute(coverage.path)) { - if (existsSync(coverage.path)) { - debug('resolving path %s', coverage.path) - coverage.path = resolve(coverage.path) - changed = true - } - } - }) - - if (changed) { - debug('saving updated file %s', nycFilename) - writeFileSync( - nycFilename, - JSON.stringify(nycCoverage, null, 2) + '\n', - 'utf8' - ) - } -} - const tasks = { /** * Clears accumulated code coverage information. @@ -143,7 +102,13 @@ const tasks = { return null } - resolvePaths(nycFilename) + showNycInfo(nycFilename) + const allSourceFilesMissing = checkAllPathsNotFound(nycFilename) + if (allSourceFilesMissing) { + tryFindingLocalFiles(nycFilename) + } + + resolveRelativePaths(nycFilename) if (customNycReportScript) { debug( diff --git a/utils.js b/utils.js index 75a43f84..be17e815 100644 --- a/utils.js +++ b/utils.js @@ -1,4 +1,30 @@ +// @ts-check /// +const { readFileSync, writeFileSync, existsSync } = require('fs') +const { isAbsolute, resolve, join } = require('path') +const debug = require('debug')('code-coverage') + +function checkAllPathsNotFound(nycFilename) { + const nycCoverage = JSON.parse(readFileSync(nycFilename, 'utf8')) + + const coverageKeys = Object.keys(nycCoverage) + if (!coverageKeys.length) { + console.error('⚠️ file %s has no coverage information', nycFilename) + return + } + + const allFilesAreMissing = coverageKeys.every((key, k) => { + const coverage = nycCoverage[key] + return !existsSync(coverage.path) + }) + + debug( + 'in file %s all files are not found? %o', + nycFilename, + allFilesAreMissing + ) + return allFilesAreMissing +} /** * remove coverage for the spec files themselves, @@ -6,6 +32,7 @@ */ const filterSpecsFromCoverage = (totalCoverage, config = Cypress.config) => { const integrationFolder = config('integrationFolder') + // @ts-ignore const testFilePattern = config('testFiles') // test files chould be: @@ -58,7 +85,176 @@ function fixSourcePathes(coverage) { }) } +/** + * A small debug utility to inspect paths saved in NYC output JSON file + */ +function showNycInfo(nycFilename) { + const nycCoverage = JSON.parse(readFileSync(nycFilename, 'utf8')) + + const coverageKeys = Object.keys(nycCoverage) + if (!coverageKeys.length) { + console.error('⚠️ file %s has no coverage information', nycFilename) + return + } + debug('NYC file %s has %d key(s)', nycFilename, coverageKeys.length) + + const maxPrintKeys = 3 + const showKeys = coverageKeys.slice(0, maxPrintKeys) + + showKeys.forEach((key, k) => { + const coverage = nycCoverage[key] + + // printing a few found keys and file paths from the coverage file + // will make debugging any problems much much easier + if (k < maxPrintKeys) { + debug('%d key %s file path %s', k + 1, key, coverage.path) + } + }) +} + +/** + * Looks at all coverage objects in the given JSON coverage file + * and if the file is relative, and exists, changes its path to + * be absolute. + */ +function resolveRelativePaths(nycFilename) { + const nycCoverage = JSON.parse(readFileSync(nycFilename, 'utf8')) + + const coverageKeys = Object.keys(nycCoverage) + if (!coverageKeys.length) { + console.error('⚠️ file %s has no coverage information', nycFilename) + return + } + debug('NYC file %s has %d key(s)', nycFilename, coverageKeys.length) + + let changed + + coverageKeys.forEach((key, k) => { + const coverage = nycCoverage[key] + + if (!coverage.path) { + debug('key %s does not have path', key) + return + } + + if (!isAbsolute(coverage.path)) { + if (existsSync(coverage.path)) { + debug('resolving path %s', coverage.path) + coverage.path = resolve(coverage.path) + changed = true + } + return + } + + // path is absolute, let's check if it exists + if (!existsSync(coverage.path)) { + debug('⚠️ cannot find file %s with hash %s', coverage.path, coverage.hash) + } + }) + + if (changed) { + debug('saving updated file %s', nycFilename) + writeFileSync( + nycFilename, + JSON.stringify(nycCoverage, null, 2) + '\n', + 'utf8' + ) + } +} + +/** + * @param {string[]} filepaths + * @returns {string | undefined} common prefix that corresponds to current folder + */ +function findCommonRoot(filepaths) { + if (!filepaths.length) { + debug('cannot find common root without any files') + return + } + + // assuming / as file separator + const splitParts = filepaths.map(name => name.split('/')) + const lengths = splitParts.map(arr => arr.length) + const shortestLength = Math.min.apply(null, lengths) + debug('shorted file path has %d parts', shortestLength) + + const cwd = process.cwd() + let commonPrefix = [] + let foundCurrentFolder + + for (let k = 0; k < shortestLength; k += 1) { + const part = splitParts[0][k] + const prefix = commonPrefix.concat(part).join('/') + debug('testing prefix %o', prefix) + const allFilesStart = filepaths.every(name => name.startsWith(prefix)) + if (!allFilesStart) { + debug('stopped at non-common prefix %s', prefix) + break + } + + commonPrefix.push(part) + + const removedPrefixNames = filepaths.map(filepath => + filepath.slice(prefix.length) + ) + debug('removedPrefix %o', removedPrefixNames) + const foundAllPaths = removedPrefixNames.every(filepath => + existsSync(join(cwd, filepath)) + ) + debug('all files found at %s? %o', prefix, foundAllPaths) + if (foundAllPaths) { + debug('found prefix that matches current folder: %s', prefix) + foundCurrentFolder = prefix + break + } + } + + return foundCurrentFolder +} + +function tryFindingLocalFiles(nycFilename) { + const nycCoverage = JSON.parse(readFileSync(nycFilename, 'utf8')) + const coverageKeys = Object.keys(nycCoverage) + const filenames = coverageKeys.map(key => nycCoverage[key].path) + const commonFolder = findCommonRoot(filenames) + if (!commonFolder) { + debug('could not find common folder %s', commonFolder) + return + } + const cwd = process.cwd() + debug( + 'found common folder %s that matches current working directory %s', + commonFolder, + cwd + ) + const length = commonFolder.length + let changed + + coverageKeys.forEach(key => { + const from = nycCoverage[key].path + if (from.startsWith(commonFolder)) { + const to = join(cwd, from.slice(length)) + nycCoverage[key].path = to + debug('replaced %s -> %s', from, to) + changed = true + } + }) + + if (changed) { + debug('saving updated file %s', nycFilename) + writeFileSync( + nycFilename, + JSON.stringify(nycCoverage, null, 2) + '\n', + 'utf8' + ) + } +} + module.exports = { fixSourcePathes, - filterSpecsFromCoverage + filterSpecsFromCoverage, + showNycInfo, + resolveRelativePaths, + checkAllPathsNotFound, + tryFindingLocalFiles }