diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..9383105 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,7 @@ +{ + "include": ["index.js"], + "branches": 100, + "functions": 100, + "statements": 100, + "lines": 100 +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bdc06f2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CI: true + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: [13] + runs-on: ${{matrix.os}} + steps: + - uses: actions/checkout@v2 + - name: Node.js ${{matrix.node-version}} on ${{matrix.os}} + uses: actions/setup-node@v1 + with: + node-version: ${{matrix.node-version}} + - run: npm install + - name: Lint + run: npm run -s pretest + - name: Tests + run: npm run -s tests-only + - name: Coverage + run: npm run -s posttest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..366cb42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.tgz +package +.nyc_output +coverage +node_modules diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9cfeb09 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.github +.c8rc.json +*.tgz +package +coverage +fixtures +tap-snapshots +test diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e1a9205 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +package-lock=false +access=public diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb93299 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +ISC License + +Copyright (c) 2020, Contributors + +Permission to use, copy, modify, and/or distribute this software +for any purpose with or without fee is hereby granted, provided +that the above copyright notice and this permission notice +appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE +LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a777b9 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# @istanbuljs/esm-loader-hook + +![Tests][tests-status] +[![NPM Version][npm-image]][npm-url] +[![NPM Downloads][downloads-image]][downloads-url] +[![ISC][license-image]](LICENSE) + +This [loader hook](https://nodejs.org/dist/latest/docs/api/esm.html#esm_hooks) +makes it relatively easy to use NYC to check coverage of ESM running in node.js +13.7.0. Note this makes use of **experimental** node.js features and thus may +stop working upon release of new versions of node.js. Until the node.js feature +is stabilized breakage should not be unexpected. + +For more stable options to test coverage you can: +* Use [c8] +* Pre-instrument your code (run `nyc instrument` then test the output) + + +## Adding to processes + +To install this hook into a process the module must be provided through the +`--experimental-loader` flag. + +The following can be used for your `npm test` script to enable live instrumentation +of ES modules being tested with mocha: + +```sh +cross-env 'NODE_OPTIONS=--experimental-loader @istanbuljs/esm-loader-hook' nyc mocha +``` + + +## Configuration + +This module executes [babel-plugin-istanbul] in a transformSource loader hook. No +options are provided to the babel plugin and babel configuration files are not honored. +Normally configuration will be provided by the currently running instance of nyc. If +this module is run outside nyc then it will use `@istanbuljs/load-nyc-config` to load +options, defaults from `@istanbuljs/schema` will apply to missing options or if no +configuration is found. + + +[tests-status]: https://github.com/cfware/node-preload/workflows/Tests/badge.svg +[npm-image]: https://img.shields.io/npm/v/@istanbuljs/esm-loader-hook.svg +[npm-url]: https://npmjs.org/package/@istanbuljs/esm-loader-hook +[downloads-image]: https://img.shields.io/npm/dm/@istanbuljs/esm-loader-hook.svg +[downloads-url]: https://npmjs.org/package/@istanbuljs/esm-loader-hook +[license-image]: https://img.shields.io/github/license/istanbuljs/esm-loader-hook + +[babel-plugin-istanbul]: https://github.com/istanbuljs/babel-plugin-istanbul#readme +[c8]: https://github.com/bcoe/c8#readme diff --git a/fixtures/hooked.js b/fixtures/hooked.js new file mode 100644 index 0000000..85dfd47 --- /dev/null +++ b/fixtures/hooked.js @@ -0,0 +1 @@ +export default () => 'value'; diff --git a/index.js b/index.js new file mode 100644 index 0000000..b07618f --- /dev/null +++ b/index.js @@ -0,0 +1,71 @@ +import {fileURLToPath} from 'url'; +import babel from '@babel/core'; +import loader from '@istanbuljs/load-nyc-config'; +import schema from '@istanbuljs/schema'; +import TestExclude from 'test-exclude'; + +function nycEnvironmentConfig() { + try { + return JSON.parse(process.env.NYC_CONFIG); + } catch { + return null; + } +} + +const initialCWD = process.env.NYC_CWD || process.cwd(); +let nycConfig = nycEnvironmentConfig(); +let testExclude; +let babelConfig; + +export async function transformSource(source, context) { + if (nycConfig === null) { + nycConfig = { + ...schema.defaults.nyc, + ...await loader.loadNycConfig({cwd: initialCWD}) + }; + } + + if (!testExclude) { + testExclude = new TestExclude(nycConfig); + babelConfig = { + ...nycConfig, + // Skip/optimize the test-exclude used within babel-plugin-istanbul + include: [], + exclude: [], + extension: [], + excludeNodeModules: false + }; + } + + if (Buffer.isBuffer(source)) { + source = source.toString(); + } else if (typeof source !== 'string') { + return {source}; + } + + const filename = fileURLToPath(context.url); + /* babel-plugin-istanbul does this but the early check optimizes */ + if (!testExclude.shouldInstrument(filename)) { + return {source}; + } + + /* Can/should this handle inputSourceMap? */ + const {code} = await babel.transformAsync(source, { + babelrc: false, + configFile: false, + filename, + /* Revisit this if transformSource adds support for returning sourceMap object */ + sourceMaps: babelConfig.produceSourceMap ? 'inline' : false, + compact: babelConfig.compact, + comments: babelConfig.preserveComments, + parserOpts: { + sourceType: 'module', + plugins: babelConfig.parserPlugins + }, + plugins: [ + ['babel-plugin-istanbul', babelConfig] + ] + }); + + return {source: code}; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dee1916 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@istanbuljs/esm-loader-hook", + "version": "0.1.0", + "description": "Loader hook for ESM instrumentation (experimental!!)", + "type": "module", + "main": "index.js", + "exports": "./index.js", + "scripts": { + "pretest": "cfware-lint .", + "tests-only": "c8 -r none node test/index.js | tap-yaml-summary", + "test": "npm run -s tests-only", + "posttest": "c8 report --check-coverage", + "snap": "cross-env TAP_SNAPSHOT=1 npm test", + "release": "standard-version" + }, + "engines": { + "node": ">=13.7.0" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/istanbuljs/esm-loader-hook.git" + }, + "bugs": { + "url": "https://github.com/istanbuljs/esm-loader-hook/issues" + }, + "homepage": "https://github.com/istanbuljs/esm-loader-hook#readme", + "dependencies": { + "@babel/core": "^7.8.7", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "babel-plugin-istanbul": "^6.0.0", + "test-exclude": "^6.0.0" + }, + "devDependencies": { + "@cfware/lint": "^1.4.0", + "c8": "^7.1.0", + "cross-env": "^7.0.2", + "libtap": "^0.3.0", + "standard-version": "^7.1.0", + "tap-yaml-summary": "^0.1.0" + } +} diff --git a/tap-snapshots/test-transform-hook.js-TAP.test.cjs b/tap-snapshots/test-transform-hook.js-TAP.test.cjs new file mode 100644 index 0000000..97cf229 --- /dev/null +++ b/tap-snapshots/test-transform-hook.js-TAP.test.cjs @@ -0,0 +1,116 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/transform-hook.js TAP > coverage after run 1`] = ` +Object { + "_coverageSchema": "", + "b": Object {}, + "branchMap": Object {}, + "f": Object { + "0": 1, + }, + "fnMap": Object { + "0": Object { + "decl": Object { + "end": Object { + "column": 16, + "line": 1, + }, + "start": Object { + "column": 15, + "line": 1, + }, + }, + "line": 1, + "loc": Object { + "end": Object { + "column": 28, + "line": 1, + }, + "start": Object { + "column": 21, + "line": 1, + }, + }, + "name": "(anonymous_0)", + }, + }, + "hash": "", + "path": "hooked.js", + "s": Object { + "0": 1, + }, + "schema": "", + "statementMap": Object { + "0": Object { + "end": Object { + "column": 28, + "line": 1, + }, + "start": Object { + "column": 21, + "line": 1, + }, + }, + }, +} +` + +exports[`test/transform-hook.js TAP > initial coverage 1`] = ` +Object { + "_coverageSchema": "", + "b": Object {}, + "branchMap": Object {}, + "f": Object { + "0": 0, + }, + "fnMap": Object { + "0": Object { + "decl": Object { + "end": Object { + "column": 16, + "line": 1, + }, + "start": Object { + "column": 15, + "line": 1, + }, + }, + "line": 1, + "loc": Object { + "end": Object { + "column": 28, + "line": 1, + }, + "start": Object { + "column": 21, + "line": 1, + }, + }, + "name": "(anonymous_0)", + }, + }, + "hash": "", + "path": "hooked.js", + "s": Object { + "0": 0, + }, + "schema": "", + "statementMap": Object { + "0": Object { + "end": Object { + "column": 28, + "line": 1, + }, + "start": Object { + "column": 21, + "line": 1, + }, + }, + }, +} +` diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..f7e29f1 --- /dev/null +++ b/test/index.js @@ -0,0 +1,64 @@ +import {fileURLToPath} from 'url'; + +import {test} from 'libtap'; +// eslint-disable-next-line import/no-unresolved +import * as loaderHook from '@istanbuljs/esm-loader-hook'; + +test('exports', async t => { + t.same(Object.keys(loaderHook), ['transformSource']); + t.match(loaderHook, {transformSource: Function}); +}); + +test('transform buffer', async t => { + const {source} = await loaderHook.transformSource( + Buffer.from('export default true;'), + { + url: new URL('../fixtures/buffer.js', import.meta.url) + } + ); + + t.match(source, /function\s+cov_/u); + t.match(source, /export\s+default\s+true/u); + t.match(source, /\/\/# sourceMappingURL=/u); +}); + +test('transform not string', async t => { + const notString = {}; + const {source} = await loaderHook.transformSource(notString); + + t.equal(source, notString); +}); + +test('transform hook', async t => { + await t.spawn( + process.execPath, + [fileURLToPath(new URL('transform-hook.js', import.meta.url).href)], + { + env: { + ...process.env, + NODE_OPTIONS: [].concat( + process.env.NODE_OPTIONS || [], + '--experimental-loader @istanbuljs/esm-loader-hook' + ).join(' ') + } + }, + 'transform1.js' + ); +}); + +test('no source maps', async t => { + await t.spawn( + process.execPath, + [fileURLToPath(new URL('no-source-maps.js', import.meta.url).href)], + { + env: { + ...process.env, + NYC_CONFIG: JSON.stringify({ + include: [], + produceSourceMap: false + }) + } + }, + 'no-source-maps.js' + ); +}); diff --git a/test/no-source-maps.js b/test/no-source-maps.js new file mode 100644 index 0000000..c070ec3 --- /dev/null +++ b/test/no-source-maps.js @@ -0,0 +1,14 @@ +import {test} from 'libtap'; +// eslint-disable-next-line import/no-unresolved +import {transformSource} from '@istanbuljs/esm-loader-hook'; + +test('check transform', async t => { + const context = { + url: new URL('../fixtures/no-source-map.js', import.meta.url) + }; + const {source} = await transformSource('export default true;', context); + + t.match(source, /function\s+cov_/u); + t.match(source, /export\s+default\s+true/u); + t.notMatch(source, /\/\/# sourceMappingURL=/u); +}); diff --git a/test/transform-hook.js b/test/transform-hook.js new file mode 100644 index 0000000..b84daa2 --- /dev/null +++ b/test/transform-hook.js @@ -0,0 +1,25 @@ +import {fileURLToPath} from 'url'; + +import t from 'libtap'; + +import hooked from '../fixtures/hooked.js'; + +const transformFile = fileURLToPath(new URL('../fixtures/hooked.js', import.meta.url).href); + +t.same(Object.keys(global.__coverage__), [transformFile]); + +const transformCoverage = global.__coverage__[transformFile]; +t.equal(transformCoverage.path, transformFile); + +const censorSnapshot = { + schema: '', + hash: '', + _coverageSchema: '', + path: 'hooked.js' +}; + +t.matchSnapshot({...transformCoverage, ...censorSnapshot}, 'initial coverage'); + +t.same(hooked(), 'value'); + +t.matchSnapshot({...transformCoverage, ...censorSnapshot}, 'coverage after run');