diff --git a/src/bundle/test-transformer.ts b/src/bundle/test-transformer.ts index 69c66812..1de7659e 100644 --- a/src/bundle/test-transformer.ts +++ b/src/bundle/test-transformer.ts @@ -5,7 +5,6 @@ import { TRANSFORM_EXTENSIONS, JS_EXTENSION_RE } from "./constants"; import { requireModuleSync } from "../utils/module"; import type { NodePath, PluginObj, TransformOptions } from "@babel/core"; -import type { ImportDeclaration } from "@babel/types"; const STYLE_EXTESTION_RE = /\.(css|less|scss|sass|styl|stylus|pcss)$/; const IGNORE_STYLE_ERRORS = ["Unexpected token"]; @@ -30,10 +29,10 @@ export const setupTransformHook = (opts: { removeNonJsImports?: boolean } = {}): ], }; - const customIgnoreImportsPlugin = (): PluginObj => ({ + const customIgnoreImportsPlugin = ({ types: t }: { types: typeof babel.types }): PluginObj => ({ name: "ignore-imports", visitor: { - ImportDeclaration(path: NodePath): void { + ImportDeclaration(path: NodePath): void { const extname = nodePath.extname(path.node.source.value); if (extname && !extname.match(JS_EXTENSION_RE)) { @@ -46,6 +45,12 @@ export const setupTransformHook = (opts: { removeNonJsImports?: boolean } = {}): } catch (err) { if (shouldIgnoreImportError(err as Error)) { path.remove(); + return; + } + + if ((err as NodeJS.ErrnoException).code === "ERR_REQUIRE_ESM") { + mockEsmModuleImport(t, path); + return; } } }, @@ -81,3 +86,106 @@ function shouldIgnoreImportError(err: Error): boolean { return isStyleFilePath; } + +/** + * Replace esm module import with a Proxy. + * Examples: + * 1) `import pkg from "package"` -> `const pkg = new Proxy({}, {get: ..., apply: ...})` + * 2) `import {a, b as c} from "package"` -> `const {a, c} = new Proxy({}, {get: ..., apply: ...})` + */ +function mockEsmModuleImport(t: typeof babel.types, path: NodePath): void { + const variableKey = genVarDeclKey(t, path.node); + const variableValue = genProxy(t, [ + t.objectExpression([]), + t.objectExpression([genProxyGetHandler(t), genProxyApplyHandler(t)]), + ]); + + const variableDecl = t.variableDeclaration("const", [t.variableDeclarator(variableKey, variableValue)]); + + path.replaceWith(variableDecl); +} + +/** + * Generates the name of variables from the import declaration. + * Examples: + * 1) `import pkg from "package"` -> `pkg` + * 2) `import {a, b as c} from "package"` -> `const {a, с} ` + */ +function genVarDeclKey( + t: typeof babel.types, + node: NodePath["node"], +): babel.types.Identifier | babel.types.ObjectPattern { + if (node.specifiers.length === 1) { + if (["ImportDefaultSpecifier", "ImportNamespaceSpecifier"].includes(node.specifiers[0].type)) { + return t.identifier(node.specifiers[0].local.name); + } + + return t.objectPattern([ + t.objectProperty( + t.identifier(node.specifiers[0].local.name), + t.identifier(node.specifiers[0].local.name), + false, + true, + ), + ]); + } + + const objectProperties = node.specifiers.map(spec => { + return t.objectProperty(t.identifier(spec.local.name), t.identifier(spec.local.name), false, true); + }); + + return t.objectPattern(objectProperties); +} + +// Generates Proxy expression with passed arguments: `new Proxy(args)` +function genProxy(t: typeof babel.types, args: babel.types.Expression[]): babel.types.NewExpression { + return t.newExpression(t.identifier("Proxy"), args); +} + +/** + * Generates "get" handler for Proxy: + * + * get: function (target, prop) { + * return prop in target ? target[prop] : new Proxy(() => {}, this); + * } + */ +function genProxyGetHandler(t: typeof babel.types): babel.types.ObjectProperty { + return t.objectProperty( + t.identifier("get"), + t.functionExpression( + null, + [t.identifier("target"), t.identifier("prop")], + t.blockStatement([ + t.returnStatement( + t.conditionalExpression( + t.binaryExpression("in", t.identifier("prop"), t.identifier("target")), + t.memberExpression(t.identifier("target"), t.identifier("prop"), true), + genProxy(t, [t.arrowFunctionExpression([], t.blockStatement([])), t.thisExpression()]), + ), + ), + ]), + ), + ); +} + +/** + * Generates "apply" handler for Proxy: + * + * apply: function () { + * return new Proxy(() => {}, this); + * } + */ +function genProxyApplyHandler(t: typeof babel.types): babel.types.ObjectProperty { + return t.objectProperty( + t.identifier("apply"), + t.functionExpression( + null, + [], + t.blockStatement([ + t.returnStatement( + genProxy(t, [t.arrowFunctionExpression([], t.blockStatement([])), t.thisExpression()]), + ), + ]), + ), + ); +} diff --git a/test/src/test-reader/test-transformer.ts b/test/src/test-reader/test-transformer.ts index 338c1305..bee90b10 100644 --- a/test/src/test-reader/test-transformer.ts +++ b/test/src/test-reader/test-transformer.ts @@ -136,6 +136,90 @@ describe("test-transformer", () => { assert.match(transformedCode, new RegExp(`require\\("${moduleName}"\\)`)); }); + + describe("replace import of esm module with a proxy", () => { + const moduleName = "esm-module"; + const error = { message: "require() of ES Module", code: "ERR_REQUIRE_ESM" }; + const expectedProxyValue = [ + `new Proxy({}, {`, + ` get: function (target, prop) {`, + ` return prop in target ? target[prop] : new Proxy(() => {}, this);`, + ` },`, + ` apply: function () {`, + ` return new Proxy(() => {}, this);`, + ` }`, + `});`, + ].join("\n"); + + let setupTransformHookStub!: typeof setupTransformHook; + + beforeEach(() => { + const { setupTransformHook } = proxyquire("../../../src/test-reader/test-transformer", { + "../bundle": proxyquire.noCallThru().load("../../../src/bundle/test-transformer", { + "../utils/module": { + requireModuleSync: sandbox.stub().withArgs(moduleName).throws(error), + }, + }), + }); + setupTransformHookStub = setupTransformHook; + }); + + it("should replace with default import", async () => { + let transformedCode; + (pirates.addHook as SinonStub).callsFake(cb => { + transformedCode = cb(`import pkg from "${moduleName}"`, moduleName); + }); + + setupTransformHookStub({ removeNonJsImports: true }); + + assert.match(transformedCode, `const pkg = ${expectedProxyValue}`); + }); + + it("should replace with namespace import", async () => { + let transformedCode; + (pirates.addHook as SinonStub).callsFake(cb => { + transformedCode = cb(`import * as pkg from "${moduleName}"`, moduleName); + }); + + setupTransformHookStub({ removeNonJsImports: true }); + + assert.match(transformedCode, `const pkg = ${expectedProxyValue}`); + }); + + it("should replace with property import", async () => { + let transformedCode; + (pirates.addHook as SinonStub).callsFake(cb => { + transformedCode = cb(`import {a, b as c} from "${moduleName}"`, moduleName); + }); + + setupTransformHookStub({ removeNonJsImports: true }); + + assert.match(transformedCode, `` + `const {\n` + ` a,\n` + ` c\n` + `} = ${expectedProxyValue}`); + }); + }); + + it("should not replace import of esm module with a proxy if it doesn't fail with 'ERR_REQUIRE_ESM' code", () => { + const moduleName = "esm-module"; + const error = { message: "Some error" }; + + const { setupTransformHook } = proxyquire("../../../src/test-reader/test-transformer", { + "../bundle": proxyquire.noCallThru().load("../../../src/bundle/test-transformer", { + "../utils/module": { + requireModuleSync: sandbox.stub().withArgs(moduleName).throws(error), + }, + }), + }); + + let transformedCode; + + (pirates.addHook as SinonStub).callsFake(cb => { + transformedCode = cb(`import "${moduleName}"`, moduleName); + }); + + setupTransformHook({ removeNonJsImports: true }); + + assert.match(transformedCode, new RegExp(`require\\("${moduleName}"\\)`)); + }); }); }); });