UNPKG

ava

Version:

Testing can be a drag. AVA helps you get it done.

267 lines (221 loc) 8.94 kB
'use strict'; const fs = require('fs'); const path = require('path'); const babel = require('@babel/core'); const concordance = require('concordance'); const convertSourceMap = require('convert-source-map'); const findUp = require('find-up'); const isPlainObject = require('is-plain-object'); const md5Hex = require('md5-hex'); const packageHash = require('package-hash'); const pkgConf = require('pkg-conf'); const stripBomBuf = require('strip-bom-buf'); const writeFileAtomic = require('write-file-atomic'); const pkg = require('../package.json'); function getSourceMap(filePath, code) { let sourceMap = convertSourceMap.fromSource(code); if (!sourceMap) { const dirPath = path.dirname(filePath); sourceMap = convertSourceMap.fromMapFileSource(code, dirPath); } return sourceMap ? sourceMap.toObject() : undefined; } function hasValidKeys(conf) { return Object.keys(conf).every(key => key === 'extensions' || key === 'testOptions'); } function isValidExtensions(extensions) { return Array.isArray(extensions) && extensions.every(ext => typeof ext === 'string' && ext !== ''); } function validate(conf) { if (conf === false) { return null; } const defaultOptions = {babelrc: true, configFile: true}; if (conf === undefined) { return {testOptions: defaultOptions}; } if ( !isPlainObject(conf) || !hasValidKeys(conf) || (conf.testOptions !== undefined && !isPlainObject(conf.testOptions)) || (conf.extensions !== undefined && !isValidExtensions(conf.extensions)) ) { throw new Error(`Unexpected Babel configuration for AVA. See https://github.com/avajs/ava/blob/v${pkg.version}/docs/recipes/babel.md for allowed values.`); } return { extensions: conf.extensions, testOptions: {...defaultOptions, ...conf.testOptions} }; } // Compare actual values rather than file paths, which should be // more reliable. function makeValueChecker(ref) { const expected = require(ref); return ({value}) => value === expected || value === expected.default; } // Resolved paths are used to create the config item, rather than the plugin // function itself, so Babel can print better error messages. // See <https://github.com/babel/babel/issues/7921>. function createConfigItem(ref, type, options = {}) { return babel.createConfigItem([require.resolve(ref), options], {type}); } // Assume the stage-4 preset is wanted if there are `userOptions`, but there is // no declaration of a stage-` preset that comes with `false` for its options. // // Ideally we'd detect the stage-4 preset anywhere in the configuration // hierarchy, but Babel's loadPartialConfig() does not return disabled presets. // See <https://github.com/babel/babel/issues/7920>. function wantsStage4(userOptions, projectDir) { if (!userOptions) { return false; } if (!userOptions.testOptions.presets) { return true; } const stage4 = require('../stage-4'); return userOptions.testOptions.presets.every(arr => { if (!Array.isArray(arr)) { return true; // There aren't any preset options. } const [ref, options] = arr; // Require the preset given the aliasing `ava/stage-4` does towards // `@ava/babel-preset-stage-4`. const resolved = require(babel.resolvePreset(ref, projectDir)); return resolved !== stage4 || options !== false; }); } function hashPartialTestConfig({babelrc, config, options: {plugins, presets}}, projectDir, pluginAndPresetHashes) { const inputs = []; if (babelrc) { inputs.push(babelrc); const filename = path.basename(babelrc); if (filename === 'package.json') { inputs.push(JSON.stringify(pkgConf.sync('babel', {cwd: path.dirname(filename)}))); } else { inputs.push(stripBomBuf(fs.readFileSync(babelrc))); } } if (config) { inputs.push(config, stripBomBuf(fs.readFileSync(config))); } for (const {file: {resolved: filename}} of [...plugins, ...presets]) { if (pluginAndPresetHashes.has(filename)) { inputs.push(pluginAndPresetHashes.get(filename)); continue; } const [firstComponent] = path.relative(projectDir, filename).split(path.sep); let hash; if (firstComponent === 'node_modules') { hash = packageHash.sync(findUp.sync('package.json', {cwd: path.dirname(filename)})); } else { hash = md5Hex(stripBomBuf(fs.readFileSync(filename))); } pluginAndPresetHashes.set(filename, hash); inputs.push(hash); } return md5Hex(inputs); } function build(projectDir, cacheDir, userOptions, compileEnhancements) { if (!userOptions && !compileEnhancements) { return null; } // Note that Babel ignores empty string values, even for NODE_ENV. Here // default to 'test' unless NODE_ENV is defined, in which case fall back to // Babel's default of 'development' if it's empty. const envName = process.env.BABEL_ENV || ('NODE_ENV' in process.env ? process.env.NODE_ENV : 'test') || 'development'; // Prepare inputs for caching seeds. Compute a seed based on the Node.js // version and the project directory. Dependency hashes may vary based on the // Node.js version, e.g. with the @ava/stage-4 Babel preset. Certain plugins // and presets are provided as absolute paths, which wouldn't necessarily // be valid if the project directory changes. Also include `envName`, so // options can be cached even if users change BABEL_ENV or NODE_ENV between // runs. const seedInputs = [ process.versions.node, packageHash.sync(require.resolve('../package.json')), projectDir, envName, concordance.serialize(concordance.describe(userOptions)) ]; const partialCacheKey = md5Hex(seedInputs); const pluginAndPresetHashes = new Map(); const ensureStage4 = wantsStage4(userOptions, projectDir); const containsAsyncGenerators = makeValueChecker('@babel/plugin-syntax-async-generators'); const containsObjectRestSpread = makeValueChecker('@babel/plugin-syntax-object-rest-spread'); const containsOptionalCatchBinding = makeValueChecker('@babel/plugin-syntax-optional-catch-binding'); const containsStage4 = makeValueChecker('../stage-4'); const containsTransformTestFiles = makeValueChecker('@ava/babel-preset-transform-test-files'); const loadOptions = filename => { const partialTestConfig = babel.loadPartialConfig({ babelrc: false, babelrcRoots: [projectDir], configFile: false, sourceMaps: true, ...userOptions && userOptions.testOptions, cwd: projectDir, envName, filename, sourceFileName: path.relative(projectDir, filename), sourceRoot: projectDir }); if (!partialTestConfig) { return {hash: '', options: null}; } const {options: testOptions} = partialTestConfig; if (!testOptions.plugins.some(containsAsyncGenerators)) { // TODO: Remove once Babel can parse this syntax unaided. testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-async-generators', 'plugin')); } if (!testOptions.plugins.some(containsObjectRestSpread)) { // TODO: Remove once Babel can parse this syntax unaided. testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-object-rest-spread', 'plugin')); } if (!testOptions.plugins.some(containsOptionalCatchBinding)) { // TODO: Remove once Babel can parse this syntax unaided. testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-optional-catch-binding', 'plugin')); } if (ensureStage4 && !testOptions.presets.some(containsStage4)) { // Apply last. testOptions.presets.unshift(createConfigItem('../stage-4', 'preset')); } if (compileEnhancements && !testOptions.presets.some(containsTransformTestFiles)) { // Apply first. testOptions.presets.push(createConfigItem('@ava/babel-preset-transform-test-files', 'preset', {powerAssert: true})); } const hash = hashPartialTestConfig(partialTestConfig, projectDir, pluginAndPresetHashes); const options = babel.loadOptions(testOptions); return {hash, options}; }; return filename => { const {hash: optionsHash, options} = loadOptions(filename); if (!options) { return null; } const contents = stripBomBuf(fs.readFileSync(filename)); const ext = path.extname(filename); const hash = md5Hex([partialCacheKey, contents, optionsHash]); const cachePath = path.join(cacheDir, `${hash}${ext}`); if (fs.existsSync(cachePath)) { return cachePath; } const inputCode = contents.toString('utf8'); const inputSourceMap = getSourceMap(filename, inputCode); if (inputSourceMap) { options.inputSourceMap = inputSourceMap; } const {code, map} = babel.transformSync(inputCode, options); if (map) { // Save source map const mapPath = `${cachePath}.map`; writeFileAtomic.sync(mapPath, JSON.stringify(map)); // Append source map comment to transformed code so that other libraries // (like nyc) can find the source map. const comment = convertSourceMap.generateMapFileComment(mapPath); writeFileAtomic.sync(cachePath, `${code}\n${comment}`); } else { writeFileAtomic.sync(cachePath, code); } return cachePath; }; } module.exports = { validate, build };