babel-plugin-mockable-imports
Advanced tools
Comparing version 1.0.0 to 1.1.0
@@ -8,4 +8,17 @@ # Changelog | ||
## [1.1.0] - 2019-04-08 | ||
- Support mocking of CommonJS / Node-style imports (`var aModule = require("a-module")`) | ||
- Support excluding imports from certain modules from being transformed for | ||
mock-ability, via the `excludeImportsFromModules` option | ||
- Support excluding modules from certain directories from being processed by | ||
this plugin via the `excludeDirs` option. By default this is configured to | ||
avoid transforming tests | ||
- Fix CommonJS default exports (`module.exports = <expression>`) overwriting the | ||
`$imports` export | ||
- Use more robust logic to avoid rewriting references to imports in the | ||
initialization of the `$imports` variable in generated code | ||
## [1.0.0] - 2019-04-07 | ||
- Initial release |
235
index.js
'use strict'; | ||
const pathModule = require('path'); | ||
const packageName = require('./package.json').name; | ||
const helperImportPath = `${packageName}/lib/helpers`; | ||
function isImportReference(path) { | ||
const name = path.node.name; | ||
const binding = path.scope.getBinding(name, /* noGlobal */ true); | ||
if (!binding) { | ||
const EXCLUDE_LIST = [ | ||
// Proxyquirify and proxyquire-universal are two popular mocking libraries | ||
// which include Browserify plugins that look for references to their imports | ||
// in the code. You'd never want to mock these, and applying the transform | ||
// here breaks the plugin. | ||
'proxyquire', | ||
]; | ||
const EXCLUDED_DIRS = ['test', '__tests__']; | ||
module.exports = ({types: t}) => { | ||
/** | ||
* Return the required module from a CallExpression, if it is a `require(...)` | ||
* call. | ||
*/ | ||
function commomJSRequireSource(node) { | ||
const args = node.arguments; | ||
if ( | ||
node.callee.name === 'require' && | ||
args.length === 1 && | ||
t.isStringLiteral(args[0]) | ||
) { | ||
return args[0].value; | ||
} else { | ||
return null; | ||
} | ||
} | ||
/** | ||
* Return true if imports from the module `source` should not be made | ||
* mockable. | ||
*/ | ||
function excludeImportsFromModule(source, state) { | ||
const excludeList = state.opts.excludeImportsFromModules || EXCLUDE_LIST; | ||
return excludeList.includes(source); | ||
} | ||
/** | ||
* Return true if the current module should not be processed at all. | ||
*/ | ||
function excludeModule(state) { | ||
const filename = state.file.opts.filename; | ||
if (!filename) { | ||
// No filename was supplied when Babel was run, assume this file should | ||
// be processed. | ||
return false; | ||
} | ||
const excludeList = state.opts.excludeDirs || EXCLUDED_DIRS; | ||
const dirParts = pathModule.dirname(filename).split(pathModule.sep); | ||
return dirParts.some(part => excludeList.includes(part)); | ||
} | ||
function isCommonJSExportAssignment(path) { | ||
const assignExpr = path.node; | ||
if (t.isMemberExpression(assignExpr.left)) { | ||
const target = assignExpr.left; | ||
if (t.isIdentifier(target.object) && t.isIdentifier(target.property)) { | ||
return ( | ||
target.object.name === 'module' && target.property.name === 'exports' | ||
); | ||
} | ||
} | ||
return false; | ||
} | ||
const importParent = binding.path.findParent(p => p.isImportDeclaration()); | ||
return importParent != null; | ||
} | ||
module.exports = ({types: t}) => { | ||
function lastExprInSequence(node) { | ||
if (t.isSequenceExpression(node)) { | ||
return node.expressions[node.expressions.length - 1]; | ||
} else { | ||
return node; | ||
} | ||
} | ||
return { | ||
@@ -33,3 +98,7 @@ visitor: { | ||
// create an innert path and use that? | ||
state.aborted = false; | ||
state.aborted = excludeModule(state); | ||
// Set to `true` if a `module.exports = <expr>` expression was seen | ||
// in the module. | ||
state.hasCommonJSExportAssignment = false; | ||
}, | ||
@@ -52,3 +121,3 @@ | ||
], | ||
t.stringLiteral(helperImportPath) | ||
t.stringLiteral(helperImportPath), | ||
); | ||
@@ -69,25 +138,115 @@ | ||
); | ||
const $importsDecl = t.exportNamedDeclaration( | ||
t.variableDeclaration('const', [ | ||
t.variableDeclarator( | ||
t.identifier('$imports'), | ||
t.newExpression(t.identifier('ImportMap'), [ | ||
importMetaObjLiteral, | ||
]), | ||
), | ||
]), | ||
[ | ||
t.exportSpecifier( | ||
t.identifier('$imports'), | ||
t.identifier('$imports'), | ||
), | ||
], | ||
); | ||
const $importsDecl = t.variableDeclaration('const', [ | ||
t.variableDeclarator( | ||
t.identifier('$imports'), | ||
t.newExpression(t.identifier('ImportMap'), [ | ||
importMetaObjLiteral, | ||
]), | ||
), | ||
]); | ||
const exportImportsDecl = t.exportNamedDeclaration(null, [ | ||
t.exportSpecifier( | ||
t.identifier('$imports'), | ||
t.identifier('$imports'), | ||
), | ||
]); | ||
// Insert `$imports` declaration below last import. | ||
const insertedNodes = state.lastImport.insertAfter(helperImport); | ||
insertedNodes[0].insertAfter($importsDecl); | ||
// Insert `export { $imports }` at the end of the file. The reason for | ||
// inserting here is that this gets converted to `exports.$imports = | ||
// $imports` if the file is later transpiled to CommonJS, and this | ||
// must come after any `module.exports = <value>` assignments. | ||
const body = path.get('body'); | ||
body[body.length - 1].insertAfter(exportImportsDecl); | ||
// If the module contains a `module.exports = <foo>` expression then | ||
// add `module.exports.$imports = <foo>` at the end of the file. | ||
if (state.hasCommonJSExportAssignment) { | ||
const moduleExportsExpr = t.memberExpression( | ||
t.memberExpression( | ||
t.identifier('module'), | ||
t.identifier('exports'), | ||
), | ||
t.identifier('$imports'), | ||
); | ||
const cjsExport = t.expressionStatement( | ||
t.assignmentExpression( | ||
'=', | ||
moduleExportsExpr, | ||
t.identifier('$imports'), | ||
), | ||
); | ||
body[body.length - 1].insertAfter(cjsExport); | ||
} | ||
}, | ||
}, | ||
AssignmentExpression(path, state) { | ||
if (!isCommonJSExportAssignment(path)) { | ||
return; | ||
} | ||
state.hasCommonJSExportAssignment = true; | ||
}, | ||
CallExpression(path, state) { | ||
const node = path.node; | ||
const source = commomJSRequireSource(node); | ||
if (!source) { | ||
return; | ||
} | ||
if (excludeImportsFromModule(source, state)) { | ||
return; | ||
} | ||
// Ignore requires that are not part of the initializer of a | ||
// `var init = require("module")` statement. | ||
const varDecl = path.findParent(p => p.isVariableDeclarator()); | ||
if (!varDecl) { | ||
return; | ||
} | ||
// If the `require` is wrapped in some way, we just ignore it, since | ||
// we cannot determine which symbols are being required without knowing | ||
// what the wrapping expression does. | ||
// | ||
// An exception is made where the `require` statement is wrapped in | ||
// a sequence (`var foo = (..., require('blah'))`) as some code coverage | ||
// transforms do. We know in this case that the result will be the | ||
// result of the require. | ||
if (lastExprInSequence(varDecl.node.init) !== node) { | ||
return; | ||
} | ||
// Ignore non-top level require expressions. | ||
if (varDecl.parentPath.parent.type !== 'Program') { | ||
return; | ||
} | ||
state.lastImport = varDecl.findParent(p => p.isVariableDeclaration()); | ||
// `var aModule = require("a-module")` | ||
const id = varDecl.node.id; | ||
if (id.type === 'Identifier') { | ||
state.importMeta.set(id, { | ||
symbol: '<CJS>', | ||
source, | ||
value: id, | ||
}); | ||
} else if (id.type === 'ObjectPattern') { | ||
// `var { aSymbol: localName } = require("a-module")` | ||
for (let property of id.properties) { | ||
state.importMeta.set(property.value, { | ||
symbol: property.key.name, | ||
source, | ||
value: property.value, | ||
}); | ||
} | ||
} | ||
}, | ||
ImportDeclaration(path, state) { | ||
@@ -125,5 +284,10 @@ if (state.aborted) { | ||
const source = path.node.source.value; | ||
if (excludeImportsFromModule(source, state)) { | ||
return; | ||
} | ||
state.importMeta.set(spec.local, { | ||
symbol: imported, | ||
source: path.node.source.value, | ||
source, | ||
value: spec.local, | ||
@@ -135,12 +299,18 @@ }); | ||
ReferencedIdentifier(child, state) { | ||
if (state.aborted || !isImportReference(child)) { | ||
if (state.aborted) { | ||
return; | ||
} | ||
const name = child.node.name; | ||
const binding = child.scope.getBinding(name, /* noGlobal */ true); | ||
if (!binding || !state.importMeta.has(binding.identifier)) { | ||
return; | ||
} | ||
// Ignore the reference in the generated `$imports` variable declaration. | ||
const newExprParent = child.findParent(p => p.isNewExpression()); | ||
const varDeclParent = child.findParent(p => p.isVariableDeclarator()); | ||
if ( | ||
newExprParent && | ||
t.isIdentifier(newExprParent.node.callee) && | ||
newExprParent.node.callee.name === 'ImportMap' | ||
varDeclParent && | ||
varDeclParent.node.id.type === 'Identifier' && | ||
varDeclParent.node.id.name === '$imports' | ||
) { | ||
@@ -181,1 +351,2 @@ return; | ||
}; | ||
('use strict'); |
@@ -59,8 +59,9 @@ "use strict"; | ||
var sourceImports = imports[source]; | ||
var esImports = sourceImports; | ||
if (typeof sourceImports === 'function') { | ||
sourceImports = { | ||
"default": sourceImports | ||
if (typeof esImports === 'function') { | ||
esImports = { | ||
"default": esImports | ||
}; | ||
} // Handle namespace imports (`import * as foo from "foo"`). | ||
} // Handle namespace ES imports (`import * as foo from "foo"`). | ||
@@ -75,14 +76,25 @@ | ||
namespaceAliases.forEach(function (alias) { | ||
_this[alias] = esImports; | ||
}); // Handle CJS imports (`var foo = require("bar")`). | ||
var cjsAliases = Object.keys(_this.$meta).filter(function (alias) { | ||
var _this$$meta$alias2 = _this.$meta[alias], | ||
source_ = _this$$meta$alias2[0], | ||
symbol_ = _this$$meta$alias2[1]; | ||
return source_ === source && symbol_ === '<CJS>'; | ||
}); | ||
cjsAliases.forEach(function (alias) { | ||
_this[alias] = sourceImports; | ||
}); // Handle named imports (`import { foo } from "..."`). | ||
}); // Handle named ES imports (`import { foo } from "..."`) or | ||
// destructured CJS imports (`var { foo } = require("...")`). | ||
Object.keys(sourceImports).forEach(function (symbol) { | ||
Object.keys(esImports).forEach(function (symbol) { | ||
var aliases = Object.keys(_this.$meta).filter(function (alias) { | ||
var _this$$meta$alias2 = _this.$meta[alias], | ||
source_ = _this$$meta$alias2[0], | ||
symbol_ = _this$$meta$alias2[1]; | ||
var _this$$meta$alias3 = _this.$meta[alias], | ||
source_ = _this$$meta$alias3[0], | ||
symbol_ = _this$$meta$alias3[1]; | ||
return source_ === source && symbol_ === symbol; | ||
}); | ||
if (aliases.length === 0 && namespaceAliases.length === 0) { | ||
if (aliases.length === 0 && namespaceAliases.length === 0 && cjsAliases.length === 0) { | ||
throw new Error("Module does not import \"" + symbol + "\" from \"" + source + "\""); | ||
@@ -92,3 +104,3 @@ } | ||
aliases.forEach(function (alias) { | ||
_this[alias] = sourceImports[symbol]; | ||
_this[alias] = esImports[symbol]; | ||
}); | ||
@@ -95,0 +107,0 @@ }); |
{ | ||
"name": "babel-plugin-mockable-imports", | ||
"version": "1.0.0", | ||
"version": "1.1.0", | ||
"description": "Babel plugin for mocking ES imports", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
@@ -7,3 +7,3 @@ # babel-plugin-mockable-imports | ||
- Provide a simple interface for mocking ES module imports | ||
- Provide a simple interface for mocking ES and CommonJS (`require`) module imports | ||
- Work when running tests in Node with any test runner or in the browser when | ||
@@ -142,5 +142,39 @@ using any bundler (Browserify, Webpack etc.) or no bunder at all | ||
You can use this to prevent the plugin from mocking imports in test modules | ||
for example. | ||
As a convenience, the plugin by default skips any files in directories named | ||
"test", "__tests__" or subdirectories of directories with those names. This | ||
can be configured using the `excludeDirs` option. | ||
### Options | ||
The plugin supports the following options: | ||
`excludeDirs` | ||
An array of directory names (eg. "tests") whose modules are excluded from | ||
this transformation by default. | ||
`excludeImportsFromModules` | ||
An array of module names which should be ignored when processing imports. | ||
Any imports from these modules will not be mockable. | ||
Default: `["proxyquire"]`. | ||
### CommonJS support | ||
The plugin has basic support for CommonJS. It will recognize the | ||
following patterns as imports: | ||
```js | ||
var foo = require('./foo'); | ||
var { foo } = require('./foo'); | ||
var { foo: bar } = require('./foo'); | ||
``` | ||
Where `var` may also be `const` or `let`. If the `require` is wrapped or | ||
contained within an expression it will not be processed. | ||
When processing a CommonJS module the plugin still emits ES6 `import` and | ||
`export` declarations, so transforming of ES6 `import`/`export` statements | ||
to CommonJS must be enabled in Babel. | ||
## How it works | ||
@@ -147,0 +181,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
26614
422
236