diff --git a/e2e/__cases__/hoisting/__test_modules__/banana.ts b/e2e/__cases__/hoisting/__test_modules__/banana.ts new file mode 100644 index 0000000000..2b55e677cd --- /dev/null +++ b/e2e/__cases__/hoisting/__test_modules__/banana.ts @@ -0,0 +1 @@ +export = 'banana' diff --git a/e2e/__cases__/hoisting/__test_modules__/mockFile.ts b/e2e/__cases__/hoisting/__test_modules__/mockFile.ts new file mode 100644 index 0000000000..e8877736ad --- /dev/null +++ b/e2e/__cases__/hoisting/__test_modules__/mockFile.ts @@ -0,0 +1,4 @@ +jest.mock('./banana', () => { + const exports = 'apple' + return exports +}) diff --git a/e2e/__cases__/hoisting/general-hoisting.spec.ts b/e2e/__cases__/hoisting/general-hoisting.spec.ts index 10ca24aea6..1426f4c43f 100644 --- a/e2e/__cases__/hoisting/general-hoisting.spec.ts +++ b/e2e/__cases__/hoisting/general-hoisting.spec.ts @@ -4,6 +4,9 @@ import b from './__test_modules__/b' import c from './__test_modules__/c' import d from './__test_modules__/d' +// The virtual mock call below will be hoisted above this `require` call. +const virtualModule = require('virtual-module') + // These will all be hoisted above imports jest.deepUnmock('./__test_modules__/Unmocked') jest.unmock('./__test_modules__/c').unmock('./__test_modules__/d') @@ -14,13 +17,23 @@ let e: any; e = require('./__test_modules__/e').default; // hoisted to the top of the function scope jest.unmock('./__test_modules__/e') -})(); +})() + +jest.mock('virtual-module', () => 'kiwi', {virtual: true}) // These will not be hoisted jest.unmock('./__test_modules__/a').dontMock('./__test_modules__/b') jest.unmock('./__test_modules__/' + 'a') jest.dontMock('./__test_modules__/Mocked') +it('does not throw during transform', () => { + const object = {}; + // @ts-expect-error + object.__defineGetter__('foo', () => 'bar'); + // @ts-expect-error + expect(object.foo).toEqual('bar'); +}) + it('hoists unmocked modules before imports', () => { // @ts-expect-error expect(Unmocked._isMockFunction).toBeUndefined() @@ -34,16 +47,26 @@ it('hoists unmocked modules before imports', () => { expect(d._isMockFunction).toBeUndefined() expect(d()).toEqual('unmocked') - expect(e._isMock).toBe(undefined) + expect(e._isMock).toBeUndefined() expect(e()).toEqual('unmocked') -}); +}) it('does not hoist dontMock calls before imports', () => { // @ts-expect-error expect(Mocked._isMockFunction).toBe(true) - expect(new Mocked().isMocked).toEqual(undefined) + expect(new Mocked().isMocked).toBeUndefined() // @ts-expect-error expect(b._isMockFunction).toBe(true) - expect(b()).toEqual(undefined) -}); + expect(b()).toBeUndefined() +}) + +it('requires modules that also call jest.mock', () => { + require('./__test_modules__/mockFile') + const mock = require('./__test_modules__/banana') + expect(mock).toEqual('apple') +}) + +it('works with virtual modules', () => { + expect(virtualModule).toBe('kiwi') +}) diff --git a/e2e/__cases__/hoisting/import-jest.spec.ts b/e2e/__cases__/hoisting/import-jest.spec.ts index dd30ace412..835ebb3ed5 100644 --- a/e2e/__cases__/hoisting/import-jest.spec.ts +++ b/e2e/__cases__/hoisting/import-jest.spec.ts @@ -2,9 +2,9 @@ import {jest} from '@jest/globals' import {jest as aliasedJest} from '@jest/globals' import * as JestGlobals from '@jest/globals' -import a from './__test_modules__/a'; -import b from './__test_modules__/b'; -import c from './__test_modules__/c'; +import a from './__test_modules__/a' +import b from './__test_modules__/b' +import c from './__test_modules__/c' // These will be hoisted above imports jest.unmock('./__test_modules__/a') @@ -17,21 +17,17 @@ test('named import', () => { // @ts-expect-error expect(a._isMockFunction).toBeUndefined() expect(a()).toBe('unmocked') -}); +}) test('aliased named import', () => { - // @ts-expect-error TODO: fix aliased named import - expect(b._isMockFunction).toBe(true) - expect(b()).toBeUndefined() - // expect(b._isMockFunction).toBe(undefined) - // expect(b()).toBe('unmocked') -}); + // @ts-expect-error + expect(b._isMockFunction).toBeUndefined() + expect(b()).toBe('unmocked') +}) test('namespace import', () => { - // @ts-expect-error TODO: fix namespace import - expect(c._isMockFunction).toBe(true) - expect(c()).toBeUndefined() - // expect(c._isMockFunction).toBe(undefined) - // expect(c()).toBe('unmocked') -}); + // @ts-expect-error + expect(c._isMockFunction).toBeUndefined() + expect(c()).toBe('unmocked') +}) diff --git a/e2e/__tests__/__snapshots__/hoisting.test.ts.snap b/e2e/__tests__/__snapshots__/hoisting.test.ts.snap index 5265ec87b1..e49d7eea02 100644 --- a/e2e/__tests__/__snapshots__/hoisting.test.ts.snap +++ b/e2e/__tests__/__snapshots__/hoisting.test.ts.snap @@ -10,7 +10,7 @@ exports[`Hoisting should pass using template "default": should pass using templa PASS ./import-jest.spec.ts Test Suites: 2 passed, 2 total - Tests: 5 passed, 5 total + Tests: 8 passed, 8 total Snapshots: 0 total Time: XXs Ran all test suites. @@ -27,7 +27,7 @@ exports[`Hoisting should pass using template "with-babel-7": should pass using t PASS ./import-jest.spec.ts Test Suites: 2 passed, 2 total - Tests: 5 passed, 5 total + Tests: 8 passed, 8 total Snapshots: 0 total Time: XXs Ran all test suites. @@ -44,7 +44,7 @@ exports[`Hoisting should pass using template "with-babel-7-string-config": shoul PASS ./import-jest.spec.ts Test Suites: 2 passed, 2 total - Tests: 5 passed, 5 total + Tests: 8 passed, 8 total Snapshots: 0 total Time: XXs Ran all test suites. diff --git a/e2e/__tests__/hoisting.test.ts b/e2e/__tests__/hoisting.test.ts index 85a8523387..5dd6118620 100644 --- a/e2e/__tests__/hoisting.test.ts +++ b/e2e/__tests__/hoisting.test.ts @@ -5,6 +5,7 @@ describe('Hoisting', () => { const testCase = configureTestCase('hoisting', { writeIo: true, jestConfig: { + testEnvironment: 'node', automock: true, }, }) diff --git a/src/config/__snapshots__/config-set.spec.ts.snap b/src/config/__snapshots__/config-set.spec.ts.snap index 0c6601dde1..dee36958c3 100644 --- a/src/config/__snapshots__/config-set.spec.ts.snap +++ b/src/config/__snapshots__/config-set.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`cacheKey should be a string 1`] = `"{\\"digest\\":\\"a0d51ca854194df8191d0e65c0ca4730f510f332\\",\\"jest\\":{\\"__backported\\":true,\\"globals\\":{}},\\"transformers\\":[\\"hoisting-jest-mock@2\\"],\\"tsJest\\":{\\"compiler\\":\\"typescript\\",\\"diagnostics\\":{\\"ignoreCodes\\":[6059,18002,18003],\\"pretty\\":true,\\"throws\\":true},\\"isolatedModules\\":false,\\"packageJson\\":{\\"kind\\":\\"file\\"},\\"transformers\\":{},\\"tsConfig\\":{\\"kind\\":\\"file\\",\\"value\\":\\"\\"}},\\"tsconfig\\":{\\"options\\":{\\"configFilePath\\":\\"\\",\\"declaration\\":false,\\"inlineSourceMap\\":false,\\"inlineSources\\":true,\\"module\\":1,\\"noEmit\\":false,\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1,\\"types\\":[]},\\"raw\\":{\\"compileOnSave\\":false,\\"compilerOptions\\":{\\"composite\\":true,\\"declaration\\":true,\\"types\\":[]},\\"exclude\\":[\\"foo/**/*\\"],\\"include\\":[\\"bar/**/*\\"]}}}"`; +exports[`cacheKey should be a string 1`] = `"{\\"digest\\":\\"a0d51ca854194df8191d0e65c0ca4730f510f332\\",\\"jest\\":{\\"__backported\\":true,\\"globals\\":{}},\\"transformers\\":[\\"hoisting-jest-mock@3\\"],\\"tsJest\\":{\\"compiler\\":\\"typescript\\",\\"diagnostics\\":{\\"ignoreCodes\\":[6059,18002,18003],\\"pretty\\":true,\\"throws\\":true},\\"isolatedModules\\":false,\\"packageJson\\":{\\"kind\\":\\"file\\"},\\"transformers\\":{},\\"tsConfig\\":{\\"kind\\":\\"file\\",\\"value\\":\\"\\"}},\\"tsconfig\\":{\\"options\\":{\\"configFilePath\\":\\"\\",\\"declaration\\":false,\\"inlineSourceMap\\":false,\\"inlineSources\\":true,\\"module\\":1,\\"noEmit\\":false,\\"removeComments\\":false,\\"sourceMap\\":true,\\"target\\":1,\\"types\\":[]},\\"raw\\":{\\"compileOnSave\\":false,\\"compilerOptions\\":{\\"composite\\":true,\\"declaration\\":true,\\"types\\":[]},\\"exclude\\":[\\"foo/**/*\\"],\\"include\\":[\\"bar/**/*\\"]}}}"`; exports[`isTestFile should return a boolean value whether the file matches test pattern 1`] = `true`; @@ -21,7 +21,7 @@ Object { "name": undefined, }, "transformers": Array [ - "hoisting-jest-mock@2", + "hoisting-jest-mock@3", ], "tsJest": Object { "babelConfig": undefined, diff --git a/src/transformers/__snapshots__/hoist-jest.spec.ts.snap b/src/transformers/__snapshots__/hoist-jest.spec.ts.snap index 2042de6e2a..41a2b7f8ff 100644 --- a/src/transformers/__snapshots__/hoist-jest.spec.ts.snap +++ b/src/transformers/__snapshots__/hoist-jest.spec.ts.snap @@ -4,21 +4,71 @@ exports[`hoisting should hoist correctly jest methods 1`] = ` "\\"use strict\\"; Object.defineProperty(exports, \\"__esModule\\", { value: true }); // These will all be hoisted above imports -jest.deepUnmock('./__test_modules__/Unmocked'); -jest.unmock('./__test_modules__/c').unmock('./__test_modules__/d'); -jest.unmock('./__test_modules__/' + 'a'); -var Unmocked_1 = require(\\"./__test_modules__/Unmocked\\"); -var a_1 = require(\\"./__test_modules__/a\\"); -var b_1 = require(\\"./__test_modules__/b\\"); -var c_1 = require(\\"./__test_modules__/c\\"); -var d_1 = require(\\"./__test_modules__/d\\"); +jest.unmock('react'); +jest.deepUnmock('../__test_modules__/Unmocked'); +jest.unmock('../__test_modules__/c').unmock('../__test_modules__/d'); +jest.mock('../__test_modules__/f', function () { + if (!global.CALLS) { + global.CALLS = 0; + } + global.CALLS++; + return { + _isMock: true, + fn: function () { + // The \`jest.mock\` transform will allow require, built-ins and globals. + var path = require('path'); + var array = new Array(3); + array[0] = path.sep; + return jest.fn(function () { return array; }); + }, + }; +}); +jest.mock(\\"../__test_modules__/jestBackticks\\"); +jest.mock('virtual-module', function () { return 'kiwi'; }, { virtual: true }); +// This has types that should be ignored by the out-of-scope variables check. +jest.mock('has-flow-types', function () { return function (props) { return 3; }; }, { + virtual: true, +}); +jest.unmock('../__test_modules__/' + 'a'); +jest.mock('../__test_modules__/f', function () { return MockMethods; }); +var Unmocked_1 = require(\\"../__test_modules__/Unmocked\\"); +var Mocked_1 = require(\\"../__test_modules__/Mocked\\"); +var a_1 = require(\\"../__test_modules__/a\\"); +var b_1 = require(\\"../__test_modules__/b\\"); +var c_1 = require(\\"../__test_modules__/c\\"); +var d_1 = require(\\"../__test_modules__/d\\"); +var jestBackticks_1 = require(\\"../__test_modules__/jestBackticks\\"); +// The virtual mock call below will be hoisted above this \`require\` call. +var virtualModule = require('virtual-module'); +var e; +(function () { + // hoisted to the top of the function scope + jest.unmock('../__test_modules__/e'); + var _getJestObj = 42; + e = require('../__test_modules__/e').default; +})(); // These will not be hoisted -jest.unmock('./__test_modules__/a').dontMock('./__test_modules__/b'); +jest.unmock('../__test_modules__/a').dontMock('../__test_modules__/b'); +jest.dontMock('../__test_modules__/Mocked'); +{ + // Would error (used before initialization) if hoisted to the top of the scope + jest.unmock('../__test_modules__/a'); + var jest = { unmock: function () { } }; +} +// This must not throw an error +var myObject = { mock: function () { } }; +myObject.mock('apple', 27); +// Variable names prefixed with \`mock\` (ignore case) should not throw as out-of-scope +var MockMethods = function () { }; console.log(Unmocked_1.default); +console.log(Mocked_1.default); console.log(a_1.default); console.log(b_1.default); console.log(c_1.default); console.log(d_1.default); +console.log(e); +console.log(virtualModule); +console.log(jestBackticks_1.default); " `; @@ -30,12 +80,12 @@ var globals_2 = require(\\"@jest/globals\\"); var JestGlobals = require(\\"@jest/globals\\"); // These will be hoisted above imports globals_1.jest.unmock('../__test_modules__/a'); +globals_2.jest.unmock('../__test_modules__/b'); +JestGlobals.jest.unmock('../__test_modules__/c'); var a_1 = require(\\"../__test_modules__/a\\"); var b_1 = require(\\"../__test_modules__/b\\"); var c_1 = require(\\"../__test_modules__/c\\"); var d_1 = require(\\"../__test_modules__/d\\"); -globals_2.jest.unmock('../__test_modules__/b'); -JestGlobals.jest.unmock('../__test_modules__/c'); // These will not be hoisted above imports { jest_1.unmock('../__test_modules__/d'); diff --git a/src/transformers/hoist-jest.spec.ts b/src/transformers/hoist-jest.spec.ts index 7f5a9c9bc8..822a34fd10 100644 --- a/src/transformers/hoist-jest.spec.ts +++ b/src/transformers/hoist-jest.spec.ts @@ -4,42 +4,100 @@ import * as tsc from 'typescript' import * as hoist from './hoist-jest' const CODE_WITH_HOISTING_NO_JEST_GLOBALS = ` -import Unmocked from './__test_modules__/Unmocked' -import a from './__test_modules__/a' -import b from './__test_modules__/b' -import c from './__test_modules__/c' -import d from './__test_modules__/d' +import React from 'react' +import Unmocked from '../__test_modules__/Unmocked' +import Mocked from '../__test_modules__/Mocked' +import a from '../__test_modules__/a' +import b from '../__test_modules__/b' +import c from '../__test_modules__/c' +import d from '../__test_modules__/d' +import f from '../__test_modules__/f' +import jestBackticks from '../__test_modules__/jestBackticks' + +// The virtual mock call below will be hoisted above this \`require\` call. +const virtualModule = require('virtual-module') // These will all be hoisted above imports -jest.deepUnmock('./__test_modules__/Unmocked') -jest.unmock('./__test_modules__/c').unmock('./__test_modules__/d') +jest.unmock('react') +jest.deepUnmock('../__test_modules__/Unmocked') +jest.unmock('../__test_modules__/c').unmock('../__test_modules__/d') + +let e; +(function () { + const _getJestObj = 42; + e = require('../__test_modules__/e').default + // hoisted to the top of the function scope + jest.unmock('../__test_modules__/e') +})() + +jest.mock('../__test_modules__/f', () => { + if (!global.CALLS) { + global.CALLS = 0 + } + global.CALLS++ + + return { + _isMock: true, + fn: () => { + // The \`jest.mock\` transform will allow require, built-ins and globals. + const path = require('path') + const array = new Array(3) + array[0] = path.sep + return jest.fn(() => array) + }, + }; +}) +jest.mock(\`../__test_modules__/jestBackticks\`) +jest.mock('virtual-module', () => 'kiwi', {virtual: true}) +// This has types that should be ignored by the out-of-scope variables check. +jest.mock('has-flow-types', () => (props: {children: mixed}) => 3, { + virtual: true, +}) // These will not be hoisted -jest.unmock('./__test_modules__/a').dontMock('./__test_modules__/b') -jest.unmock('./__test_modules__/' + 'a') +jest.unmock('../__test_modules__/a').dontMock('../__test_modules__/b') +jest.unmock('../__test_modules__/' + 'a') +jest.dontMock('../__test_modules__/Mocked') +{ + const jest = {unmock: () => {}}; + // Would error (used before initialization) if hoisted to the top of the scope + jest.unmock('../__test_modules__/a') +} + +// This must not throw an error +const myObject = {mock: () => {}} +myObject.mock('apple', 27) + +// Variable names prefixed with \`mock\` (ignore case) should not throw as out-of-scope +const MockMethods = () => {} +jest.mock('../__test_modules__/f', () => MockMethods) console.log(Unmocked) +console.log(Mocked) console.log(a) console.log(b) console.log(c) console.log(d) +console.log(e) +console.log(virtualModule) +console.log(jestBackticks) ` const CODE_WITH_HOISTING_HAS_JEST_GLOBALS = ` - import a from '../__test_modules__/a'; - import b from '../__test_modules__/b'; + import a from '../__test_modules__/a' + import b from '../__test_modules__/b' - import {jest} from '@jest/globals'; - import {jest as aliasedJest} from '@jest/globals'; - import * as JestGlobals from '@jest/globals'; + import {jest} from '@jest/globals' + import {jest as aliasedJest} from '@jest/globals' + import * as JestGlobals from '@jest/globals' - import c from '../__test_modules__/c'; - import d from '../__test_modules__/d'; + import c from '../__test_modules__/c' + import d from '../__test_modules__/d' // These will be hoisted above imports - jest.unmock('../__test_modules__/a'); - aliasedJest.unmock('../__test_modules__/b'); - JestGlobals.jest.unmock('../__test_modules__/c'); + jest.unmock('../__test_modules__/a') + aliasedJest.unmock('../__test_modules__/b') + JestGlobals.jest.unmock('../__test_modules__/c') // These will not be hoisted above imports @@ -66,7 +124,6 @@ describe('hoisting', () => { expect(typeof hoist.factory).toBe('function') }) - // TODO: import alias and import * are not hoisted correctly yet, will need to fix it.each([CODE_WITH_HOISTING_NO_JEST_GLOBALS, CODE_WITH_HOISTING_HAS_JEST_GLOBALS])( 'should hoist correctly jest methods', (data) => { diff --git a/src/transformers/hoist-jest.ts b/src/transformers/hoist-jest.ts index 34e906bba0..b345595e73 100644 --- a/src/transformers/hoist-jest.ts +++ b/src/transformers/hoist-jest.ts @@ -1,8 +1,8 @@ -// take care of including ONLY TYPES here, for the rest use `ts` import { LogContexts, LogLevels } from 'bs-logger' import type { Block, ExpressionStatement, + ImportDeclaration, Node, SourceFile, Statement, @@ -14,20 +14,22 @@ import type { import type { ConfigSet } from '../config/config-set' /** - * What methods of `jest` should we hoist + * What methods of `jest` we should hoist */ const HOIST_METHODS = ['mock', 'unmock', 'enableAutomock', 'disableAutomock', 'deepUnmock'] -const JEST_GLOBAL_NAME = 'jest' const JEST_GLOBALS_MODULE_NAME = '@jest/globals' +const JEST_GLOBAL_NAME = 'jest' +const ROOT_LEVEL_AST = 1 /** * @internal */ export const name = 'hoisting-jest-mock' -// increment this each time the code is modified /** + * Please increment this each time the code is modified + * * @internal */ -export const version = 2 +export const version = 3 /** * The factory of hoisting transformer factory @@ -41,16 +43,33 @@ export function factory(cs: ConfigSet): (ctx: TransformationContext) => Transfor * To access Program or TypeChecker, do: cs.tsCompiler.program or cs.tsCompiler.program.getTypeChecker() */ const ts = cs.compilerModule + const importNames: string[] = [] - function shouldHoistExpression(expression: Node): boolean { - return ( - ts.isCallExpression(expression) && - ts.isPropertyAccessExpression(expression.expression) && - HOIST_METHODS.includes(expression.expression.name.text) && - ((ts.isIdentifier(expression.expression.expression) && - expression.expression.expression.text === JEST_GLOBAL_NAME) || - shouldHoistExpression(expression.expression.expression)) - ) + function shouldHoistExpression(node: Node): boolean { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + HOIST_METHODS.includes(node.expression.name.text) + ) { + if (importNames.length) { + // @jest/globals is in used + return ( + (ts.isIdentifier(node.expression.expression) && importNames.includes(node.expression.expression.text)) || + (ts.isPropertyAccessExpression(node.expression.expression) && + ts.isIdentifier(node.expression.expression.expression) && + importNames.includes(node.expression.expression.expression.text)) || + shouldHoistExpression(node.expression.expression) + ) + } else { + // @jest/globals is not in used + return ( + (ts.isIdentifier(node.expression.expression) && node.expression.expression.text === JEST_GLOBAL_NAME) || + shouldHoistExpression(node.expression.expression) + ) + } + } + + return false } /** @@ -60,7 +79,7 @@ export function factory(cs: ConfigSet): (ctx: TransformationContext) => Transfor return ts.isExpressionStatement(node) && shouldHoistExpression(node.expression) } - function isJestGlobalImport(node: Statement): boolean { + function isJestGlobalImport(node: Node): node is ImportDeclaration { return ( ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && @@ -115,6 +134,23 @@ export function factory(cs: ConfigSet): (ctx: TransformationContext) => Transfor // visit each child let resultNode = ts.visitEachChild(node, visitor, ctx) + /** + * Gather all possible import names, from different types of import syntax including: + * - named import, e.g. `import { jest } from '@jest/globals'` + * - aliased named import, e.g. `import {jest as aliasedJest} from '@jest/globals'` + * - namespace import, e.g `import * as JestGlobals from '@jest/globals'` + */ + if ( + isJestGlobalImport(resultNode) && + resultNode.importClause?.namedBindings && + (ts.isNamespaceImport(resultNode.importClause.namedBindings) || + ts.isNamedImports(resultNode.importClause.namedBindings)) + ) { + const { namedBindings } = resultNode.importClause + importNames.push( + ts.isNamespaceImport(namedBindings) ? namedBindings.name.text : namedBindings.elements[0].name.text, + ) + } // check if we have something to hoist in this level if (hoisted[level] && hoisted[level].length) { // re-order children so that hoisted ones appear first @@ -125,8 +161,9 @@ export function factory(cs: ConfigSet): (ctx: TransformationContext) => Transfor ) const newNode = ts.getMutableClone(resultNode) as Block const newStatements = [...hoistedStmts, ...otherStmts] - if (level === 1) { + if (level === ROOT_LEVEL_AST) { const jestGlobalsImportStmts = (resultNode as Block).statements.filter((s) => isJestGlobalImport(s)) + // jest methods should not be hoisted higher than import `@jest/globals` resultNode = { ...newNode, statements: ts.createNodeArray([...jestGlobalsImportStmts, ...newStatements]),