From bc5ea3e5ffb419d18baf5504429baa9ef924fcd0 Mon Sep 17 00:00:00 2001 From: Corey Farrell Date: Sat, 5 Oct 2019 08:13:01 -0400 Subject: [PATCH] feat: Add support for loading config from ESM modules (#7) Fixes #6 --- .taprc | 3 +- index.js | 65 ++++++++++++------- load-esm.js | 10 +++ nyc-settings.js | 13 ++++ package.json | 16 +++-- tap-snapshots/test-basic.js-TAP.test.js | 24 +++++++ tap-snapshots/test-env-nyc-cwd.js-TAP.test.js | 2 +- tap-snapshots/test-process-cwd.js-TAP.test.js | 2 +- test/basic.js | 54 +++++++++++---- test/env-nyc-cwd.js | 11 ++-- test/fixtures/extends/invalid.cjs | 1 + test/fixtures/extends/invalid.js | 1 + test/fixtures/extends/invalid.mjs | 2 + test/fixtures/nyc-config-async/nyc.config.js | 12 ++++ test/fixtures/nyc-config-async/package.json | 5 ++ test/fixtures/nyc-config-cjs/nyc.config.cjs | 3 + test/fixtures/nyc-config-cjs/package.json | 2 + .../nyc-config-js-type-module/nyc.config.js | 3 + .../nyc-config-js-type-module/package.json | 6 ++ test/fixtures/nyc-config-mjs/nyc.config.mjs | 3 + test/fixtures/nyc-config-mjs/package.json | 5 ++ test/helpers/esm-tester.mjs | 1 + test/helpers/index.js | 31 +++++++-- test/process-cwd.js | 13 ++-- 24 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 load-esm.js create mode 100644 nyc-settings.js create mode 100644 test/fixtures/extends/invalid.cjs create mode 100644 test/fixtures/extends/invalid.js create mode 100644 test/fixtures/extends/invalid.mjs create mode 100644 test/fixtures/nyc-config-async/nyc.config.js create mode 100644 test/fixtures/nyc-config-async/package.json create mode 100644 test/fixtures/nyc-config-cjs/nyc.config.cjs create mode 100644 test/fixtures/nyc-config-cjs/package.json create mode 100644 test/fixtures/nyc-config-js-type-module/nyc.config.js create mode 100644 test/fixtures/nyc-config-js-type-module/package.json create mode 100644 test/fixtures/nyc-config-mjs/nyc.config.mjs create mode 100644 test/fixtures/nyc-config-mjs/package.json create mode 100644 test/helpers/esm-tester.mjs diff --git a/.taprc b/.taprc index 6fb65d2..b92f75a 100644 --- a/.taprc +++ b/.taprc @@ -1,4 +1,5 @@ { "test-ignore": "helpers", - "check-coverage": true + "esm": false, + "nyc-arg": "--nycrc-path=nyc-settings.js" } diff --git a/index.js b/index.js index b16608f..54dde74 100644 --- a/index.js +++ b/index.js @@ -2,18 +2,35 @@ const fs = require('fs'); const path = require('path'); +const {promisify} = require('util'); const camelcase = require('camelcase'); const findUp = require('find-up'); const resolveFrom = require('resolve-from'); +const readFile = promisify(fs.readFile); + const standardConfigFiles = [ '.nycrc', '.nycrc.json', '.nycrc.yml', '.nycrc.yaml', - 'nyc.config.js' + 'nyc.config.js', + 'nyc.config.cjs', + 'nyc.config.mjs' ]; +async function moduleLoader(file) { + try { + return require(file); + } catch (error) { + if (error.code !== 'ERR_REQUIRE_ESM') { + throw error; + } + + return require('./load-esm')(file); + } +} + function camelcasedConfig(config) { const results = {}; for (const [field, value] of Object.entries(config)) { @@ -23,13 +40,13 @@ function camelcasedConfig(config) { return results; } -function findPackage(options) { +async function findPackage(options) { const cwd = options.cwd || process.env.NYC_CWD || process.cwd(); - const pkgPath = findUp.sync('package.json', {cwd}); + const pkgPath = await findUp('package.json', {cwd}); if (pkgPath) { return { cwd: path.dirname(pkgPath), - pkgConfig: JSON.parse(fs.readFileSync(pkgPath, 'utf8')).nyc || {} + pkgConfig: JSON.parse(await readFile(pkgPath, 'utf8')).nyc || {} }; } @@ -39,7 +56,7 @@ function findPackage(options) { }; } -function actualLoad(configFile) { +async function actualLoad(configFile) { if (!configFile) { return {}; } @@ -47,22 +64,21 @@ function actualLoad(configFile) { const configExt = path.extname(configFile).toLowerCase(); switch (configExt) { case '.js': + case '.mjs': + return moduleLoader(configFile); + case '.cjs': return require(configFile); case '.yml': case '.yaml': - return require('js-yaml').load( - fs.readFileSync(configFile, 'utf8') - ); + return require('js-yaml').load(await readFile(configFile, 'utf8')); default: - return JSON.parse( - fs.readFileSync(configFile, 'utf8') - ); + return JSON.parse(await readFile(configFile, 'utf8')); } } -function applyExtends(config, filename, loopCheck = new Set()) { +async function applyExtends(config, filename, loopCheck = new Set()) { config = camelcasedConfig(config); - if (Object.prototype.hasOwnProperty.call(config, 'extends')) { + if ('extends' in config) { const extConfigs = [].concat(config.extends); if (extConfigs.some(e => typeof e !== 'string')) { throw new TypeError(`${filename} contains an invalid 'extends' option`); @@ -70,7 +86,7 @@ function applyExtends(config, filename, loopCheck = new Set()) { delete config.extends; const filePath = path.dirname(filename); - return extConfigs.reduce((config, extConfig) => { + for (const extConfig of extConfigs) { const configFile = resolveFrom.silent(filePath, extConfig) || resolveFrom.silent(filePath, './' + extConfig); if (!configFile) { @@ -82,27 +98,28 @@ function applyExtends(config, filename, loopCheck = new Set()) { } loopCheck.add(configFile); - return { - ...config, - ...applyExtends(actualLoad(configFile), configFile, loopCheck) - }; - }, config); + Object.assign( + config, + // eslint-disable-next-line no-await-in-loop + await applyExtends(await actualLoad(configFile), configFile, loopCheck) + ); + } } return config; } -function loadNycConfig(options = {}) { - const {cwd, pkgConfig} = findPackage(options); +async function loadNycConfig(options = {}) { + const {cwd, pkgConfig} = await findPackage(options); const configFiles = [].concat(options.nycrcPath || standardConfigFiles); - const configFile = findUp.sync(configFiles, {cwd}); + const configFile = await findUp(configFiles, {cwd}); if (options.nycrcPath && !configFile) { throw new Error(`Requested configuration file ${options.nycrcPath} not found`); } const config = { - ...applyExtends(pkgConfig, path.join(cwd, 'package.json')), - ...applyExtends(actualLoad(configFile), configFile) + ...(await applyExtends(pkgConfig, path.join(cwd, 'package.json'))), + ...(await applyExtends(await actualLoad(configFile), configFile)) }; const arrayFields = ['require', 'extension', 'exclude', 'include']; diff --git a/load-esm.js b/load-esm.js new file mode 100644 index 0000000..e2350c2 --- /dev/null +++ b/load-esm.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = async filename => { + const mod = await import(filename); + if ('default' in mod === false) { + throw new Error(`${filename} has no default export`); + } + + return mod.default; +}; diff --git a/nyc-settings.js b/nyc-settings.js new file mode 100644 index 0000000..1dff53f --- /dev/null +++ b/nyc-settings.js @@ -0,0 +1,13 @@ +'use strict'; + +const {hasImport} = require('./test/helpers'); + +const include = [ + 'index.js' +]; + +if (hasImport) { + include.push('load-esm.js'); +} + +module.exports = {include}; diff --git a/package.json b/package.json index dbea7a6..f645e11 100644 --- a/package.json +++ b/package.json @@ -23,16 +23,22 @@ "homepage": "https://github.com/istanbuljs/load-nyc-config#readme", "dependencies": { "camelcase": "^5.3.1", - "find-up": "^4.0.0", + "find-up": "^4.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" }, "devDependencies": { - "standard-version": "^6.0.1", - "tap": "^14.0.0", - "xo": "^0.24.0" + "standard-version": "^7.0.0", + "tap": "^14.6.5", + "xo": "^0.25.3" }, "xo": { - "ignores": "tap-snapshots/**" + "ignores": [ + "test/fixtures/extends/invalid.*", + "tap-snapshots/**" + ], + "rules": { + "require-atomic-updates": 0 + } } } diff --git a/tap-snapshots/test-basic.js-TAP.test.js b/tap-snapshots/test-basic.js-TAP.test.js index 487d975..d912723 100644 --- a/tap-snapshots/test-basic.js-TAP.test.js +++ b/tap-snapshots/test-basic.js-TAP.test.js @@ -30,6 +30,18 @@ Object { } ` +exports[`test/basic.js TAP esm nyc-config-js-type-module > must match snapshot 1`] = ` +Object { + "all": false, +} +` + +exports[`test/basic.js TAP esm nyc-config-mjs > must match snapshot 1`] = ` +Object { + "all": false, +} +` + exports[`test/basic.js TAP extends > must match snapshot 1`] = ` Object { "all": false, @@ -65,6 +77,18 @@ Object { } ` +exports[`test/basic.js TAP nyc-config-async > must match snapshot 1`] = ` +Object { + "all": false, +} +` + +exports[`test/basic.js TAP nyc-config-cjs > must match snapshot 1`] = ` +Object { + "all": false, +} +` + exports[`test/basic.js TAP nyc-config-js > must match snapshot 1`] = ` Object { "all": false, diff --git a/tap-snapshots/test-env-nyc-cwd.js-TAP.test.js b/tap-snapshots/test-env-nyc-cwd.js-TAP.test.js index 4e9d7ac..9f3f605 100644 --- a/tap-snapshots/test-env-nyc-cwd.js-TAP.test.js +++ b/tap-snapshots/test-env-nyc-cwd.js-TAP.test.js @@ -5,7 +5,7 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/env-nyc-cwd.js TAP > must match snapshot 1`] = ` +exports[`test/env-nyc-cwd.js TAP env-nyc-cwd > must match snapshot 1`] = ` Object { "all": true, } diff --git a/tap-snapshots/test-process-cwd.js-TAP.test.js b/tap-snapshots/test-process-cwd.js-TAP.test.js index 3d92518..c689886 100644 --- a/tap-snapshots/test-process-cwd.js-TAP.test.js +++ b/tap-snapshots/test-process-cwd.js-TAP.test.js @@ -5,7 +5,7 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/process-cwd.js TAP > must match snapshot 1`] = ` +exports[`test/process-cwd.js TAP process-cwd > must match snapshot 1`] = ` Object { "all": true, } diff --git a/test/basic.js b/test/basic.js index b3aea0b..3dc6f55 100644 --- a/test/basic.js +++ b/test/basic.js @@ -1,12 +1,11 @@ const t = require('tap'); -const {fixturePath, basicTest} = require('./helpers'); +const {fixturePath, basicTest, hasImport, hasESM} = require('./helpers'); const {loadNycConfig} = require('..'); -t.test('options.nycrcPath points to non-existent file', t => { +t.test('options.nycrcPath points to non-existent file', async t => { const cwd = fixturePath(); const nycrcPath = fixturePath('does-not-exist.json'); - t.throws(() => loadNycConfig({cwd, nycrcPath})); - t.end(); + await t.rejects(loadNycConfig({cwd, nycrcPath})); }); t.test('no-config-file', basicTest); @@ -14,27 +13,56 @@ t.test('nycrc-no-ext', basicTest); t.test('nycrc-json', basicTest); t.test('nycrc-yml', basicTest); t.test('nycrc-yaml', basicTest); +t.test('nyc-config-cjs', basicTest); t.test('nyc-config-js', basicTest); +t.test('nyc-config-async', basicTest); t.test('array-field-fixup', basicTest); t.test('camel-decamel', basicTest); t.test('extends', basicTest); t.test('extends-array-empty', basicTest); t.test('extends-array', basicTest); -t.test('extends failures', t => { - const errorConfigs = ['looper1.json', 'invalid.json', 'missing.json']; +t.test('extends failures', async t => { const cwd = fixturePath('extends'); - errorConfigs.map(f => fixturePath('extends', f)).forEach(nycrcPath => { - t.throws(() => loadNycConfig({cwd, nycrcPath})); + const files = { + 'looper1.json': /Circular extended configurations/, + 'invalid.json': /contains an invalid 'extends' option/, + 'invalid.js': /Unexpected identifier/, + 'invalid.cjs': /Unexpected identifier/, + 'missing.json': /Could not resolve configuration file/ + }; + if (hasImport && await hasESM()) { + files['invalid.mjs'] = /has no default export/; + } + + const tests = Object.entries(files).map(([file, error]) => { + return t.rejects(loadNycConfig({ + cwd, + nycrcPath: fixturePath('extends', file) + }), error, file); }); - t.end(); + + await Promise.all(tests); }); -t.test('no package.json', t => { +t.test('no package.json', async t => { const cwd = '/'; const nycrcPath = fixturePath('nycrc-no-ext', '.nycrc'); - t.matchSnapshot(loadNycConfig({cwd}), 'no config'); - t.matchSnapshot(loadNycConfig({cwd, nycrcPath}), 'explicit .nycrc'); - t.end(); + t.matchSnapshot(await loadNycConfig({cwd}), 'no config'); + t.matchSnapshot(await loadNycConfig({cwd, nycrcPath}), 'explicit .nycrc'); }); + +if (hasImport) { + t.test('esm', async t => { + if (await hasESM()) { + if (process.versions.node.split('.')[0] >= 12) { + await t.test('nyc-config-js-type-module', basicTest); + } + + await t.test('nyc-config-mjs', basicTest); + } else { + t.pass('we have import but it doesn\'t support ES modules'); + } + }); +} diff --git a/test/env-nyc-cwd.js b/test/env-nyc-cwd.js index 055602c..823f0ca 100644 --- a/test/env-nyc-cwd.js +++ b/test/env-nyc-cwd.js @@ -2,10 +2,11 @@ const t = require('tap'); const {fixturePath} = require('./helpers'); const {loadNycConfig} = require('..'); -const saved = process.env.NYC_CWD; -process.env.NYC_CWD = fixturePath('no-config-file'); +t.test('env-nyc-cwd', async t => { + const saved = process.env.NYC_CWD; + process.env.NYC_CWD = fixturePath('no-config-file'); -t.matchSnapshot(loadNycConfig()); -t.end(); + t.matchSnapshot(await loadNycConfig()); -process.env.NYC_CWD = saved; + process.env.NYC_CWD = saved; +}); diff --git a/test/fixtures/extends/invalid.cjs b/test/fixtures/extends/invalid.cjs new file mode 100644 index 0000000..282df90 --- /dev/null +++ b/test/fixtures/extends/invalid.cjs @@ -0,0 +1 @@ +syntax error! diff --git a/test/fixtures/extends/invalid.js b/test/fixtures/extends/invalid.js new file mode 100644 index 0000000..282df90 --- /dev/null +++ b/test/fixtures/extends/invalid.js @@ -0,0 +1 @@ +syntax error! diff --git a/test/fixtures/extends/invalid.mjs b/test/fixtures/extends/invalid.mjs new file mode 100644 index 0000000..e22b21f --- /dev/null +++ b/test/fixtures/extends/invalid.mjs @@ -0,0 +1,2 @@ +// This is not supported, config must be provided in the default export. +export const all = true; diff --git a/test/fixtures/nyc-config-async/nyc.config.js b/test/fixtures/nyc-config-async/nyc.config.js new file mode 100644 index 0000000..e50333f --- /dev/null +++ b/test/fixtures/nyc-config-async/nyc.config.js @@ -0,0 +1,12 @@ +'use strict'; + +const {promisify} = require('util'); + +const delay = promisify(setTimeout); + +async function loadConfig() { + await delay(10); + return {all: false}; +} + +module.exports = loadConfig(); diff --git a/test/fixtures/nyc-config-async/package.json b/test/fixtures/nyc-config-async/package.json new file mode 100644 index 0000000..1034e28 --- /dev/null +++ b/test/fixtures/nyc-config-async/package.json @@ -0,0 +1,5 @@ +{ + "nyc": { + "all": true + } +} diff --git a/test/fixtures/nyc-config-cjs/nyc.config.cjs b/test/fixtures/nyc-config-cjs/nyc.config.cjs new file mode 100644 index 0000000..0a491ed --- /dev/null +++ b/test/fixtures/nyc-config-cjs/nyc.config.cjs @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = {all: false}; diff --git a/test/fixtures/nyc-config-cjs/package.json b/test/fixtures/nyc-config-cjs/package.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/test/fixtures/nyc-config-cjs/package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/test/fixtures/nyc-config-js-type-module/nyc.config.js b/test/fixtures/nyc-config-js-type-module/nyc.config.js new file mode 100644 index 0000000..e50ea13 --- /dev/null +++ b/test/fixtures/nyc-config-js-type-module/nyc.config.js @@ -0,0 +1,3 @@ +export default { + all: false +}; diff --git a/test/fixtures/nyc-config-js-type-module/package.json b/test/fixtures/nyc-config-js-type-module/package.json new file mode 100644 index 0000000..cef14db --- /dev/null +++ b/test/fixtures/nyc-config-js-type-module/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "nyc": { + "all": true + } +} diff --git a/test/fixtures/nyc-config-mjs/nyc.config.mjs b/test/fixtures/nyc-config-mjs/nyc.config.mjs new file mode 100644 index 0000000..e50ea13 --- /dev/null +++ b/test/fixtures/nyc-config-mjs/nyc.config.mjs @@ -0,0 +1,3 @@ +export default { + all: false +}; diff --git a/test/fixtures/nyc-config-mjs/package.json b/test/fixtures/nyc-config-mjs/package.json new file mode 100644 index 0000000..1034e28 --- /dev/null +++ b/test/fixtures/nyc-config-mjs/package.json @@ -0,0 +1,5 @@ +{ + "nyc": { + "all": true + } +} diff --git a/test/helpers/esm-tester.mjs b/test/helpers/esm-tester.mjs new file mode 100644 index 0000000..7b34e19 --- /dev/null +++ b/test/helpers/esm-tester.mjs @@ -0,0 +1 @@ +export default 'pass'; diff --git a/test/helpers/index.js b/test/helpers/index.js index 200a25d..36b9ecf 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -5,14 +5,37 @@ function fixturePath(...args) { return path.join(__dirname, '..', 'fixtures', ...args); } -function basicTest(t) { +async function basicTest(t) { const cwd = fixturePath(t.name); - t.matchSnapshot(loadNycConfig({cwd})); - t.end(); + t.matchSnapshot(await loadNycConfig({cwd})); +} + +function canImport() { + try { + return typeof require('../../load-esm') === 'function'; + } catch (_) { + return false; + } +} + +async function hasESM() { + try { + const loader = require('../../load-esm'); + try { + await loader(require.resolve('./esm-tester.mjs')); + return true; + } catch (_) { + return false; + } + } catch (_) { + return false; + } } module.exports = { fixturePath, - basicTest + basicTest, + hasImport: canImport(), + hasESM }; diff --git a/test/process-cwd.js b/test/process-cwd.js index 3644b42..aa49d3f 100644 --- a/test/process-cwd.js +++ b/test/process-cwd.js @@ -2,12 +2,13 @@ const t = require('tap'); const {fixturePath} = require('./helpers'); const {loadNycConfig} = require('..'); -const saved = process.env.NYC_CWD; -delete process.env.NYC_CWD; +t.test('process-cwd', async t => { + const saved = process.env.NYC_CWD; + delete process.env.NYC_CWD; -process.chdir(fixturePath('no-config-file')); + process.chdir(fixturePath('no-config-file')); -t.matchSnapshot(loadNycConfig()); -t.end(); + t.matchSnapshot(await loadNycConfig()); -process.env.NYC_CWD = saved; + process.env.NYC_CWD = saved; +});