Skip to content

Commit

Permalink
feat(compiler): allow isolatedModule: true to have ESM support (#2219)
Browse files Browse the repository at this point in the history
Introduce a new config option `useESM`. When this option is true and Jest runtime supports ESM for certain files, `ts-jest` will transform those files to ESM syntax based on the `module` defined in user tsconfig.

If `useESM: true` and Jest supports ESM syntax for the file and `module` from tsconfig is not `es2015/es2020/esnext`, `esnext` will be used as default.

Related to #1709
  • Loading branch information
ahnpnl committed Dec 17, 2020
1 parent e1ba1a0 commit e101db0
Show file tree
Hide file tree
Showing 16 changed files with 173 additions and 2,103 deletions.
1,021 changes: 0 additions & 1,021 deletions e2e/__tests__/module-kinds/__snapshots__/es2015-esm.test.ts.snap

This file was deleted.

1,021 changes: 0 additions & 1,021 deletions e2e/__tests__/module-kinds/__snapshots__/esnext-esm.test.ts.snap

This file was deleted.

9 changes: 0 additions & 9 deletions e2e/__tests__/module-kinds/es2015-esm.test.ts

This file was deleted.

9 changes: 0 additions & 9 deletions e2e/__tests__/module-kinds/esnext-esm.test.ts

This file was deleted.

12 changes: 12 additions & 0 deletions src/compiler/__snapshots__/ts-compiler.spec.ts.snap
Expand Up @@ -171,3 +171,15 @@ exports[`TsCompiler isolatedModule true should compile js file for allowJs true
version: 3
================================================================================
`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 1`] = `99`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 2`] = `99`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 3`] = `99`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 4`] = `1`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 5`] = `1`;

exports[`TsCompiler isolatedModule true support ESM should transpile codes to correct syntax with supportsStaticESM and useESM options 6`] = `1`;
52 changes: 52 additions & 0 deletions src/compiler/ts-compiler.spec.ts
@@ -1,6 +1,7 @@
import { readFileSync } from 'fs'
import { LogLevels } from 'bs-logger'
import { join } from 'path'
import ts from 'typescript'

import { TS_JEST_OUT_DIR } from '../config/config-set'
import { makeCompiler } from '../__helpers__/fakers'
Expand Down Expand Up @@ -160,6 +161,57 @@ const t: string = f(5)
).not.toThrowError()
})
})

describe('support ESM', () => {
test.each([
{
supportsStaticESM: true,
useESM: true,
moduleKind: 'esnext',
},
{
supportsStaticESM: true,
useESM: true,
moduleKind: 'amd',
},
{
supportsStaticESM: true,
useESM: true,
moduleKind: undefined,
},
{
supportsStaticESM: false,
useESM: true,
moduleKind: 'esnext',
},
{
supportsStaticESM: true,
useESM: false,
moduleKind: 'amd',
},
{
supportsStaticESM: false,
useESM: false,
moduleKind: 'es2015',
},
])('should transpile codes to correct syntax with supportsStaticESM and useESM options', (data) => {
const transpileModuleSpy = (ts.transpileModule = jest.fn().mockReturnValueOnce({
outputText: 'var foo = 1',
diagnostics: [],
sourceMapText: '{}',
}))
const fileContent = `const foo = import('./foo')`
const fileName = 'foo.ts'

const compiler = makeCompiler({
tsJestConfig: { ...baseTsJestConfig, tsconfig: { module: data.moduleKind as any }, useESM: data.useESM },
})
compiler.getCompiledOutput(fileContent, fileName, data.supportsStaticESM)

expect(transpileModuleSpy).toHaveBeenCalled()
expect(transpileModuleSpy.mock.calls[0][1].compilerOptions.module).toMatchSnapshot()
})
})
})

describe('isolatedModule false', () => {
Expand Down
38 changes: 27 additions & 11 deletions src/compiler/ts-compiler.ts
@@ -1,13 +1,14 @@
import { LogContexts, Logger, LogLevels } from 'bs-logger'
import memoize from 'lodash.memoize'
import { basename, normalize, relative } from 'path'
import type {
import {
EmitOutput,
LanguageService,
LanguageServiceHost,
ParsedCommandLine,
ResolvedModuleFull,
TranspileOutput,
ModuleKind,
} from 'typescript'

import { updateOutput } from './compiler-utils'
Expand All @@ -17,6 +18,8 @@ import type { CompilerInstance, ResolvedModulesMap, StringMap, TTypeScript } fro
import { rootLogger } from '../utils/logger'
import { Errors, interpolate } from '../utils/messages'

const AVAILABLE_ESM_MODULE_KINDS = [ModuleKind.ES2015, ModuleKind.ES2020, ModuleKind.ESNext]

/**
* @internal
*/
Expand All @@ -41,6 +44,10 @@ export class TsCompiler implements CompilerInstance {
}

private _createLanguageService(): void {
const compilerOptions = {
...this._parsedTsConfig.options,
module: ModuleKind.CommonJS,
}
const serviceHostTraceCtx = {
namespace: 'ts:serviceHost',
call: null,
Expand All @@ -60,11 +67,7 @@ export class TsCompiler implements CompilerInstance {
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
getDirectories: memoize(this._ts.sys.getDirectories),
}
const moduleResolutionCache = this._ts.createModuleResolutionCache(
this.configSet.cwd,
(x) => x,
this._parsedTsConfig.options,
)
const moduleResolutionCache = this._ts.createModuleResolutionCache(this.configSet.cwd, (x) => x, compilerOptions)
/* istanbul ignore next */
const serviceHost: LanguageServiceHost = {
getProjectVersion: () => String(this._projectVersion),
Expand Down Expand Up @@ -109,15 +112,15 @@ export class TsCompiler implements CompilerInstance {
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
getNewLine: () => LINE_FEED,
getCurrentDirectory: () => this.configSet.cwd,
getCompilationSettings: () => this._parsedTsConfig.options,
getDefaultLibFileName: () => this._ts.getDefaultLibFilePath(this._parsedTsConfig.options),
getCompilationSettings: () => compilerOptions,
getDefaultLibFileName: () => this._ts.getDefaultLibFilePath(compilerOptions),
getCustomTransformers: () => this.configSet.customTransformers,
resolveModuleNames: (moduleNames: string[], containingFile: string): (ResolvedModuleFull | undefined)[] =>
moduleNames.map((moduleName) => {
const { resolvedModule } = this._ts.resolveModuleName(
moduleName,
containingFile,
this._parsedTsConfig.options,
compilerOptions,
moduleResolutionHost,
moduleResolutionCache,
)
Expand All @@ -138,7 +141,7 @@ export class TsCompiler implements CompilerInstance {
return (this._languageService?.getProgram()?.getSourceFile(fileName) as any)?.resolvedModules
}

getCompiledOutput(fileContent: string, fileName: string): string {
getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM: boolean): string {
if (this._languageService) {
this._logger.debug({ fileName }, 'getCompiledOutput(): compiling using language service')

Expand All @@ -164,12 +167,25 @@ export class TsCompiler implements CompilerInstance {

return updateOutput(output.outputFiles[1].text, fileName, output.outputFiles[0].text)
} else {
let moduleKind = this._parsedTsConfig.options.module
if (supportsStaticESM && this.configSet.useESM) {
moduleKind =
!moduleKind || (moduleKind && !AVAILABLE_ESM_MODULE_KINDS.includes(moduleKind))
? ModuleKind.ESNext
: moduleKind
} else {
moduleKind = ModuleKind.CommonJS
}

this._logger.debug({ fileName }, 'getCompiledOutput(): compiling as isolated module')

const result: TranspileOutput = this._ts.transpileModule(fileContent, {
fileName,
transformers: this.configSet.customTransformers,
compilerOptions: this._parsedTsConfig.options,
compilerOptions: {
...this._parsedTsConfig.options,
module: moduleKind,
},
reportDiagnostics: this.configSet.shouldReportDiagnostics(fileName),
})
if (result.diagnostics && this.configSet.shouldReportDiagnostics(fileName)) {
Expand Down
4 changes: 2 additions & 2 deletions src/compiler/ts-jest-compiler.ts
Expand Up @@ -17,7 +17,7 @@ export class TsJestCompiler implements CompilerInstance {
return this._compilerInstance.getResolvedModulesMap(fileContent, fileName)
}

getCompiledOutput(fileContent: string, fileName: string): string {
return this._compilerInstance.getCompiledOutput(fileContent, fileName)
getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM = false): string {
return this._compilerInstance.getCompiledOutput(fileContent, fileName, supportsStaticESM)
}
}
17 changes: 4 additions & 13 deletions src/config/config-set.spec.ts
Expand Up @@ -47,8 +47,7 @@ describe('parsedTsConfig', () => {
})

it('should override some options', () => {
expect(get({ tsconfig: { module: 'esnext' as any, inlineSources: false } }).options).toMatchObject({
module: ts.ModuleKind.CommonJS,
expect(get({ tsconfig: { inlineSources: false } }).options).toMatchObject({
inlineSources: true,
})
})
Expand All @@ -70,19 +69,19 @@ describe('parsedTsConfig', () => {
})
})

it('should warn about possibly wrong module config and set synth. default imports', () => {
it('should warn about possibly wrong module config and set synth. default imports with module None/AMD/UMD/System', () => {
const target = logTargetMock()
target.clear()
const cs = createConfigSet({
tsJestConfig: {
tsconfig: { module: 'ES6', esModuleInterop: false } as any,
tsconfig: { module: 'AMD', esModuleInterop: false } as any,
diagnostics: { warnOnly: true, pretty: false },
},
resolve: null,
})

expect(cs.parsedTsConfig.options).toMatchObject({
module: ts.ModuleKind.CommonJS,
module: ts.ModuleKind.AMD,
allowSyntheticDefaultImports: true,
esModuleInterop: false,
})
Expand Down Expand Up @@ -692,7 +691,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.options.allowSyntheticDefaultImports).toEqual(true)
expect(conf.errors).toMatchSnapshot()
expect(cs.parsedTsConfig.options.module).toEqual(ts.ModuleKind.CommonJS)
})

it('should use given tsconfig path', () => {
Expand All @@ -715,7 +713,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][2]).toBe('/foo')
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.errors).toMatchSnapshot()
expect(cs.parsedTsConfig.options.module).not.toEqual(ts.ModuleKind.CommonJS)
})
})

Expand Down Expand Up @@ -756,7 +753,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.options.allowSyntheticDefaultImports).toEqual(true)
expect(conf.errors).toMatchSnapshot()
expect(cs.parsedTsConfig.options.module).toEqual(ts.ModuleKind.CommonJS)
})

it('should use given tsconfig path', () => {
Expand All @@ -779,7 +775,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][2]).toBe('/foo')
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.errors).toMatchSnapshot()
expect(cs.parsedTsConfig.options.module).not.toEqual(ts.ModuleKind.CommonJS)
})
})

Expand Down Expand Up @@ -820,7 +815,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.options.allowSyntheticDefaultImports).toBeUndefined()
expect(conf.errors).toEqual([])
expect(cs.parsedTsConfig.options.module).toEqual(ts.ModuleKind.CommonJS)
})

it('should use given tsconfig path', () => {
Expand All @@ -843,7 +837,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][2]).toBe('/foo')
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.errors).toEqual([])
expect(cs.parsedTsConfig.options.module).not.toEqual(ts.ModuleKind.CommonJS)
})
})

Expand Down Expand Up @@ -884,7 +877,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.errors).toEqual([])
expect(conf.options.allowSyntheticDefaultImports).toEqual(true)
expect(cs.parsedTsConfig.options.module).toEqual(ts.ModuleKind.CommonJS)
})

it('should use given tsconfig path', () => {
Expand All @@ -908,7 +900,6 @@ describe('_resolveTsConfig', () => {
expect(parseConfig.mock.calls[0][4]).toBe(tscfgPathStub)
expect(conf.errors).toEqual([])
expect(conf.options.allowSyntheticDefaultImports).toEqual(true)
expect(cs.parsedTsConfig.options.module).not.toEqual(ts.ModuleKind.CommonJS)
})
})
})
Expand Down
30 changes: 15 additions & 15 deletions src/config/config-set.ts
Expand Up @@ -12,14 +12,15 @@ import type { Config } from '@jest/types'
import { LogContexts, Logger } from 'bs-logger'
import { existsSync, readFileSync } from 'fs'
import { globsToMatcher } from 'jest-util'
import json5 = require('json5')
import json5 from 'json5'
import { dirname, extname, isAbsolute, join, normalize, resolve } from 'path'
import {
CompilerOptions,
CustomTransformers,
Diagnostic,
FormatDiagnosticsHost,
ParsedCommandLine,
ModuleKind,
ScriptTarget,
} from 'typescript'

Expand Down Expand Up @@ -115,6 +116,7 @@ export class ConfigSet {
tsCacheDir: string | undefined
parsedTsConfig!: ParsedCommandLine | Record<string, any>
customTransformers: CustomTransformers = Object.create(null)
useESM = false
/**
* @internal
*/
Expand Down Expand Up @@ -183,7 +185,7 @@ export class ConfigSet {
this.logger.debug({ compilerModule: this.compilerModule }, 'normalized compiler module config via ts-jest option')

this._backportJestCfg()
this._setupTsJestCfg(options)
this._setupConfigSet(options)
this._resolveTsCacheDir()
this._matchablePatterns = [...this._jestCfg.testMatch, ...this._jestCfg.testRegex].filter(
(pattern) =>
Expand Down Expand Up @@ -215,7 +217,10 @@ export class ConfigSet {
/**
* @internal
*/
private _setupTsJestCfg(options: TsJestGlobalOptions): void {
private _setupConfigSet(options: TsJestGlobalOptions): void {
// useESM
this.useESM = options.useESM ?? false

// babel config (for babel-jest) default is undefined so we don't need to have fallback like tsConfig
if (!options.babelConfig) {
this.logger.debug('babel is disabled')
Expand Down Expand Up @@ -244,12 +249,7 @@ export class ConfigSet {
}

this.logger.debug({ babelConfig: this.babelConfig }, 'normalized babel config via ts-jest option')
}
if (!this.babelConfig) {
this._overriddenCompilerOptions.module = this.jestConfig.extensionsToTreatAsEsm.length
? undefined
: this.compilerModule.ModuleKind.CommonJS
} else {

this.babelJestTransformer = importer
.babelJest(ImportReasons.BabelJest)
.createTransformer(this.babelConfig) as BabelJestTransformer
Expand Down Expand Up @@ -412,19 +412,19 @@ export class ConfigSet {
const finalOptions = result.options
// Target ES2015 output by default (instead of ES3).
if (finalOptions.target === undefined) {
finalOptions.target = ts.ScriptTarget.ES2015
finalOptions.target = ScriptTarget.ES2015
}

// check the module interoperability
const target = finalOptions.target
// compute the default if not set
const defaultModule = [ts.ScriptTarget.ES3, ts.ScriptTarget.ES5].includes(target)
? ts.ModuleKind.CommonJS
: ts.ModuleKind.ESNext
const defaultModule = [ScriptTarget.ES3, ScriptTarget.ES5].includes(target)
? ModuleKind.CommonJS
: ModuleKind.ESNext
const moduleValue = finalOptions.module ?? defaultModule
if (
'module' in forcedOptions &&
moduleValue !== forcedOptions.module &&
!this.babelConfig &&
moduleValue !== ModuleKind.CommonJS &&
!(finalOptions.esModuleInterop || finalOptions.allowSyntheticDefaultImports)
) {
result.errors.push({
Expand Down

0 comments on commit e101db0

Please sign in to comment.