UNPKG

8.94 kBJavaScriptView Raw
1'use strict';
2const fs = require('fs');
3const path = require('path');
4const babel = require('@babel/core');
5const concordance = require('concordance');
6const convertSourceMap = require('convert-source-map');
7const findUp = require('find-up');
8const isPlainObject = require('is-plain-object');
9const md5Hex = require('md5-hex');
10const packageHash = require('package-hash');
11const pkgConf = require('pkg-conf');
12const stripBomBuf = require('strip-bom-buf');
13const writeFileAtomic = require('write-file-atomic');
14const pkg = require('../package.json');
15
16function getSourceMap(filePath, code) {
17 let sourceMap = convertSourceMap.fromSource(code);
18
19 if (!sourceMap) {
20 const dirPath = path.dirname(filePath);
21 sourceMap = convertSourceMap.fromMapFileSource(code, dirPath);
22 }
23
24 return sourceMap ? sourceMap.toObject() : undefined;
25}
26
27function hasValidKeys(conf) {
28 return Object.keys(conf).every(key => key === 'extensions' || key === 'testOptions');
29}
30
31function isValidExtensions(extensions) {
32 return Array.isArray(extensions) && extensions.every(ext => typeof ext === 'string' && ext !== '');
33}
34
35function validate(conf) {
36 if (conf === false) {
37 return null;
38 }
39
40 const defaultOptions = {babelrc: true, configFile: true};
41
42 if (conf === undefined) {
43 return {testOptions: defaultOptions};
44 }
45
46 if (
47 !isPlainObject(conf) ||
48 !hasValidKeys(conf) ||
49 (conf.testOptions !== undefined && !isPlainObject(conf.testOptions)) ||
50 (conf.extensions !== undefined && !isValidExtensions(conf.extensions))
51 ) {
52 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.`);
53 }
54
55 return {
56 extensions: conf.extensions,
57 testOptions: {...defaultOptions, ...conf.testOptions}
58 };
59}
60
61// Compare actual values rather than file paths, which should be
62// more reliable.
63function makeValueChecker(ref) {
64 const expected = require(ref);
65 return ({value}) => value === expected || value === expected.default;
66}
67
68// Resolved paths are used to create the config item, rather than the plugin
69// function itself, so Babel can print better error messages.
70// See <https://github.com/babel/babel/issues/7921>.
71function createConfigItem(ref, type, options = {}) {
72 return babel.createConfigItem([require.resolve(ref), options], {type});
73}
74
75// Assume the stage-4 preset is wanted if there are `userOptions`, but there is
76// no declaration of a stage-` preset that comes with `false` for its options.
77//
78// Ideally we'd detect the stage-4 preset anywhere in the configuration
79// hierarchy, but Babel's loadPartialConfig() does not return disabled presets.
80// See <https://github.com/babel/babel/issues/7920>.
81function wantsStage4(userOptions, projectDir) {
82 if (!userOptions) {
83 return false;
84 }
85
86 if (!userOptions.testOptions.presets) {
87 return true;
88 }
89
90 const stage4 = require('../stage-4');
91 return userOptions.testOptions.presets.every(arr => {
92 if (!Array.isArray(arr)) {
93 return true; // There aren't any preset options.
94 }
95
96 const [ref, options] = arr;
97 // Require the preset given the aliasing `ava/stage-4` does towards
98 // `@ava/babel-preset-stage-4`.
99 const resolved = require(babel.resolvePreset(ref, projectDir));
100 return resolved !== stage4 || options !== false;
101 });
102}
103
104function hashPartialTestConfig({babelrc, config, options: {plugins, presets}}, projectDir, pluginAndPresetHashes) {
105 const inputs = [];
106 if (babelrc) {
107 inputs.push(babelrc);
108
109 const filename = path.basename(babelrc);
110 if (filename === 'package.json') {
111 inputs.push(JSON.stringify(pkgConf.sync('babel', {cwd: path.dirname(filename)})));
112 } else {
113 inputs.push(stripBomBuf(fs.readFileSync(babelrc)));
114 }
115 }
116
117 if (config) {
118 inputs.push(config, stripBomBuf(fs.readFileSync(config)));
119 }
120
121 for (const {file: {resolved: filename}} of [...plugins, ...presets]) {
122 if (pluginAndPresetHashes.has(filename)) {
123 inputs.push(pluginAndPresetHashes.get(filename));
124 continue;
125 }
126
127 const [firstComponent] = path.relative(projectDir, filename).split(path.sep);
128 let hash;
129 if (firstComponent === 'node_modules') {
130 hash = packageHash.sync(findUp.sync('package.json', {cwd: path.dirname(filename)}));
131 } else {
132 hash = md5Hex(stripBomBuf(fs.readFileSync(filename)));
133 }
134
135 pluginAndPresetHashes.set(filename, hash);
136 inputs.push(hash);
137 }
138
139 return md5Hex(inputs);
140}
141
142function build(projectDir, cacheDir, userOptions, compileEnhancements) {
143 if (!userOptions && !compileEnhancements) {
144 return null;
145 }
146
147 // Note that Babel ignores empty string values, even for NODE_ENV. Here
148 // default to 'test' unless NODE_ENV is defined, in which case fall back to
149 // Babel's default of 'development' if it's empty.
150 const envName = process.env.BABEL_ENV || ('NODE_ENV' in process.env ? process.env.NODE_ENV : 'test') || 'development';
151
152 // Prepare inputs for caching seeds. Compute a seed based on the Node.js
153 // version and the project directory. Dependency hashes may vary based on the
154 // Node.js version, e.g. with the @ava/stage-4 Babel preset. Certain plugins
155 // and presets are provided as absolute paths, which wouldn't necessarily
156 // be valid if the project directory changes. Also include `envName`, so
157 // options can be cached even if users change BABEL_ENV or NODE_ENV between
158 // runs.
159 const seedInputs = [
160 process.versions.node,
161 packageHash.sync(require.resolve('../package.json')),
162 projectDir,
163 envName,
164 concordance.serialize(concordance.describe(userOptions))
165 ];
166
167 const partialCacheKey = md5Hex(seedInputs);
168 const pluginAndPresetHashes = new Map();
169
170 const ensureStage4 = wantsStage4(userOptions, projectDir);
171 const containsAsyncGenerators = makeValueChecker('@babel/plugin-syntax-async-generators');
172 const containsObjectRestSpread = makeValueChecker('@babel/plugin-syntax-object-rest-spread');
173 const containsOptionalCatchBinding = makeValueChecker('@babel/plugin-syntax-optional-catch-binding');
174 const containsStage4 = makeValueChecker('../stage-4');
175 const containsTransformTestFiles = makeValueChecker('@ava/babel-preset-transform-test-files');
176
177 const loadOptions = filename => {
178 const partialTestConfig = babel.loadPartialConfig({
179 babelrc: false,
180 babelrcRoots: [projectDir],
181 configFile: false,
182 sourceMaps: true,
183 ...userOptions && userOptions.testOptions,
184 cwd: projectDir,
185 envName,
186 filename,
187 sourceFileName: path.relative(projectDir, filename),
188 sourceRoot: projectDir
189 });
190
191 if (!partialTestConfig) {
192 return {hash: '', options: null};
193 }
194
195 const {options: testOptions} = partialTestConfig;
196 if (!testOptions.plugins.some(containsAsyncGenerators)) { // TODO: Remove once Babel can parse this syntax unaided.
197 testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-async-generators', 'plugin'));
198 }
199
200 if (!testOptions.plugins.some(containsObjectRestSpread)) { // TODO: Remove once Babel can parse this syntax unaided.
201 testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-object-rest-spread', 'plugin'));
202 }
203
204 if (!testOptions.plugins.some(containsOptionalCatchBinding)) { // TODO: Remove once Babel can parse this syntax unaided.
205 testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-optional-catch-binding', 'plugin'));
206 }
207
208 if (ensureStage4 && !testOptions.presets.some(containsStage4)) {
209 // Apply last.
210 testOptions.presets.unshift(createConfigItem('../stage-4', 'preset'));
211 }
212
213 if (compileEnhancements && !testOptions.presets.some(containsTransformTestFiles)) {
214 // Apply first.
215 testOptions.presets.push(createConfigItem('@ava/babel-preset-transform-test-files', 'preset', {powerAssert: true}));
216 }
217
218 const hash = hashPartialTestConfig(partialTestConfig, projectDir, pluginAndPresetHashes);
219 const options = babel.loadOptions(testOptions);
220 return {hash, options};
221 };
222
223 return filename => {
224 const {hash: optionsHash, options} = loadOptions(filename);
225 if (!options) {
226 return null;
227 }
228
229 const contents = stripBomBuf(fs.readFileSync(filename));
230 const ext = path.extname(filename);
231 const hash = md5Hex([partialCacheKey, contents, optionsHash]);
232 const cachePath = path.join(cacheDir, `${hash}${ext}`);
233
234 if (fs.existsSync(cachePath)) {
235 return cachePath;
236 }
237
238 const inputCode = contents.toString('utf8');
239 const inputSourceMap = getSourceMap(filename, inputCode);
240 if (inputSourceMap) {
241 options.inputSourceMap = inputSourceMap;
242 }
243
244 const {code, map} = babel.transformSync(inputCode, options);
245
246 if (map) {
247 // Save source map
248 const mapPath = `${cachePath}.map`;
249 writeFileAtomic.sync(mapPath, JSON.stringify(map));
250
251 // Append source map comment to transformed code so that other libraries
252 // (like nyc) can find the source map.
253 const comment = convertSourceMap.generateMapFileComment(mapPath);
254 writeFileAtomic.sync(cachePath, `${code}\n${comment}`);
255 } else {
256 writeFileAtomic.sync(cachePath, code);
257 }
258
259 return cachePath;
260 };
261}
262
263module.exports = {
264 validate,
265 build
266};