Skip to content

Commit f372121

Browse files
committedMar 24, 2025·
feat: support hybrid module values for isolatedModules: true
1 parent 273dba1 commit f372121

11 files changed

+719
-27
lines changed
 

‎jest-src.config.ts ‎jest.config.ts

File renamed without changes.

‎package-lock.json

+176
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "29.2.6",
44
"main": "dist/index.js",
55
"types": "dist/index.d.ts",
6+
"type": "commonjs",
67
"bin": {
78
"ts-jest": "cli.js"
89
},
@@ -11,7 +12,7 @@
1112
"prebuild": "rimraf --glob dist coverage *.tgz",
1213
"build": "tsc -p tsconfig.build.json",
1314
"postbuild": "node scripts/post-build.js",
14-
"test": "jest -c=jest-src.config.ts",
15+
"test": "jest -c=jest.config.ts",
1516
"test-e2e-cjs": "jest -c=jest-e2e.config.cjs --no-cache",
1617
"test-e2e-esm": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest-e2e.config.mjs --no-cache",
1718
"test-examples": "node scripts/test-examples.js",
@@ -128,6 +129,7 @@
128129
"jest": "^29.7.0",
129130
"js-yaml": "^4.1.0",
130131
"lint-staged": "^15.5.0",
132+
"memfs": "^4.17.0",
131133
"prettier": "^2.8.8",
132134
"rimraf": "^5.0.10",
133135
"typescript": "~5.5.4",

‎src/__helpers__/workspace-root.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
function workspaceRootInner(dir: string, candidateRoot: string) {
5+
if (path.dirname(dir) === dir) return candidateRoot
6+
7+
const matchablePaths = [path.join(dir, '.ts-jest-digest'), path.join(dir, '.github'), path.join(dir, 'renovate.json')]
8+
if (matchablePaths.some((x) => fs.existsSync(x))) {
9+
return dir
10+
}
11+
12+
return workspaceRootInner(path.dirname(dir), candidateRoot)
13+
}
14+
15+
export const workspaceRoot = workspaceRootInner(process.cwd(), process.cwd())

‎src/legacy/compiler/__snapshots__/ts-compiler.spec.ts.snap

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "supportsStaticESM": false, "useESM": true} 1`] = `
3+
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "module": "CommonJS", "supportsStaticESM": false, "useESM": true} 1`] = `
44
{
55
"allowSyntheticDefaultImports": undefined,
66
"customConditions": undefined,
@@ -9,7 +9,7 @@ exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code
99
}
1010
`;
1111

12-
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "supportsStaticESM": true, "useESM": false} 1`] = `
12+
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "module": "CommonJS", "supportsStaticESM": true, "useESM": false} 1`] = `
1313
{
1414
"allowSyntheticDefaultImports": undefined,
1515
"customConditions": undefined,
@@ -18,7 +18,7 @@ exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code
1818
}
1919
`;
2020

21-
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "supportsStaticESM": true, "useESM": true} 1`] = `
21+
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": false, "module": "ESNext", "supportsStaticESM": true, "useESM": true} 1`] = `
2222
{
2323
"allowSyntheticDefaultImports": undefined,
2424
"customConditions": undefined,
@@ -27,7 +27,7 @@ exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code
2727
}
2828
`;
2929

30-
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": true, "supportsStaticESM": false, "useESM": true} 1`] = `
30+
exports[`TsCompiler getCompiledOutput isolatedModules true should transpile code with config {"babelConfig": true, "module": "CommonJS", "supportsStaticESM": false, "useESM": true} 1`] = `
3131
{
3232
"allowSyntheticDefaultImports": undefined,
3333
"customConditions": undefined,

‎src/legacy/compiler/ts-compiler.spec.ts

+43-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { readFileSync } from 'fs'
22
import { basename, join, normalize } from 'path'
33

4+
import type { TsConfigJson } from 'type-fest'
45
import type { CompilerOptions, EmitOutput, transpileModule, TranspileOutput } from 'typescript'
5-
import * as ts from 'typescript'
6+
import ts from 'typescript'
67

78
import { createConfigSet, makeCompiler } from '../../__helpers__/fakers'
89
import type { RawCompilerOptions } from '../../raw-compiler-options'
10+
import { tsTranspileModule } from '../../transpilers/typescript/transpile-module'
911
import type { DepGraphInfo } from '../../types'
1012
import { Errors, interpolate } from '../../utils/messages'
1113

@@ -18,9 +20,17 @@ jest.mock('typescript', () => {
1820
return {
1921
__esModule: true,
2022
...actualModule,
23+
default: actualModule,
24+
}
25+
})
26+
jest.mock('../../transpilers/typescript/transpile-module', () => {
27+
return {
28+
tsTranspileModule: jest.fn(),
2129
}
2230
})
2331

32+
const mockTsTranspileModule = jest.mocked(tsTranspileModule)
33+
2434
const mockFolder = join(process.cwd(), 'src', '__mocks__')
2535

2636
const baseTsJestConfig = { tsconfig: join(process.cwd(), 'tsconfig.json') }
@@ -95,23 +105,27 @@ describe('TsCompiler', () => {
95105
useESM: true,
96106
babelConfig: true,
97107
supportsStaticESM: false,
108+
module: 'CommonJS',
98109
},
99110
{
100111
useESM: true,
101112
babelConfig: false,
102113
supportsStaticESM: true,
114+
module: 'ESNext',
103115
},
104116
{
105117
useESM: true,
106118
babelConfig: false,
107119
supportsStaticESM: false,
120+
module: 'CommonJS',
108121
},
109122
{
110123
useESM: false,
111124
babelConfig: false,
112125
supportsStaticESM: true,
126+
module: 'CommonJS',
113127
},
114-
])('should transpile code with config %p', ({ useESM, babelConfig, supportsStaticESM }) => {
128+
])('should transpile code with config %p', ({ useESM, babelConfig, supportsStaticESM, module }) => {
115129
const compiler = makeCompiler({
116130
tsJestConfig: {
117131
...baseTsJestConfig,
@@ -120,7 +134,8 @@ describe('TsCompiler', () => {
120134
tsconfig: {
121135
isolatedModules: true,
122136
customConditions: ['my-condition'],
123-
},
137+
module,
138+
} as TsConfigJson,
124139
},
125140
})
126141
const transformersStub = {
@@ -159,6 +174,7 @@ describe('TsCompiler', () => {
159174
useESM: false,
160175
tsconfig: {
161176
isolatedModules: true,
177+
module: 'CommonJS',
162178
},
163179
},
164180
})
@@ -197,6 +213,30 @@ describe('TsCompiler', () => {
197213
expect(compiler.configSet.raiseDiagnostics).not.toHaveBeenCalled()
198214
}
199215
})
216+
217+
it('should use tsTranspileModule when Node16/NodeNext is used', () => {
218+
const compiler = makeCompiler({
219+
tsJestConfig: {
220+
...baseTsJestConfig,
221+
tsconfig: {
222+
isolatedModules: true,
223+
module: 'Node16',
224+
moduleResolution: 'Node16',
225+
},
226+
},
227+
})
228+
mockTsTranspileModule.mockReturnValueOnce({
229+
outputText: '',
230+
})
231+
232+
compiler.getCompiledOutput(fileContent, fileName, {
233+
depGraphs: new Map(),
234+
supportsStaticESM: true,
235+
watchMode: false,
236+
})
237+
238+
expect(mockTsTranspileModule).toHaveBeenCalled()
239+
})
200240
})
201241

202242
describe('isolatedModules false', () => {

‎src/legacy/compiler/ts-compiler.ts

+48-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { basename, normalize } from 'path'
22

33
import { LogContexts, Logger, LogLevels } from 'bs-logger'
44
import memoize from 'lodash.memoize'
5-
import type {
5+
import ts, {
66
Bundle,
77
CompilerOptions,
88
CustomTransformerFactory,
@@ -23,6 +23,8 @@ import type {
2323
} from 'typescript'
2424

2525
import { LINE_FEED, TS_TSX_REGEX } from '../../constants'
26+
// import { tsTranspileModule } from '../../transpilers/typescript/transpile-module'
27+
import { tsTranspileModule } from '../../transpilers/typescript/transpile-module'
2628
import type {
2729
StringMap,
2830
TsCompilerInstance,
@@ -32,11 +34,35 @@ import type {
3234
} from '../../types'
3335
import { CompiledOutput } from '../../types'
3436
import { rootLogger } from '../../utils'
35-
import { Errors, interpolate } from '../../utils/messages'
37+
import { Errors, Helps, interpolate } from '../../utils/messages'
3638
import type { ConfigSet } from '../config/config-set'
3739

3840
import { updateOutput } from './compiler-utils'
3941

42+
const isModernNodeResolution = (module: ts.ModuleKind | undefined): boolean => {
43+
return module ? [ts.ModuleKind.Node16, /* ModuleKind.Node18 */ 101, ts.ModuleKind.NodeNext].includes(module) : false
44+
}
45+
46+
const shouldUseNativeTsTranspile = (compilerOptions: ts.CompilerOptions | undefined): boolean => {
47+
if (!compilerOptions) {
48+
return true
49+
}
50+
51+
const { module } = compilerOptions
52+
53+
return !isModernNodeResolution(module)
54+
}
55+
56+
const assertCompilerOptionsWithJestTransformMode = (
57+
compilerOptions: ts.CompilerOptions,
58+
isEsmMode: boolean,
59+
logger: Logger,
60+
): void => {
61+
if (isEsmMode && compilerOptions.module === ts.ModuleKind.CommonJS) {
62+
logger.error(Errors.InvalidModuleKindForEsm)
63+
}
64+
}
65+
4066
export class TsCompiler implements TsCompilerInstance {
4167
protected readonly _logger: Logger
4268
protected readonly _ts: TTypeScript
@@ -162,7 +188,7 @@ export class TsCompiler implements TsCompilerInstance {
162188

163189
let moduleKind = compilerOptions.module ?? this._ts.ModuleKind.ESNext
164190
let esModuleInterop = compilerOptions.esModuleInterop
165-
if ([this._ts.ModuleKind.Node16, this._ts.ModuleKind.NodeNext].includes(moduleKind)) {
191+
if (isModernNodeResolution(moduleKind)) {
166192
esModuleInterop = true
167193
moduleKind = this._ts.ModuleKind.ESNext
168194
}
@@ -182,6 +208,10 @@ export class TsCompiler implements TsCompilerInstance {
182208
getCompiledOutput(fileContent: string, fileName: string, options: TsJestCompileOptions): CompiledOutput {
183209
const isEsmMode = this.configSet.useESM && options.supportsStaticESM
184210
this._compilerOptions = this.fixupCompilerOptionsForModuleKind(this._initialCompilerOptions, isEsmMode)
211+
if (!this._initialCompilerOptions.isolatedModules && isModernNodeResolution(this._initialCompilerOptions.module)) {
212+
this._logger.warn(Helps.UsingModernNodeResolution)
213+
}
214+
185215
const moduleKind = this._initialCompilerOptions.module
186216
const currentModuleKind = this._compilerOptions.module
187217
if (this._languageService) {
@@ -251,7 +281,9 @@ export class TsCompiler implements TsCompilerInstance {
251281
} else {
252282
this._logger.debug({ fileName }, 'getCompiledOutput(): compiling as isolated module')
253283

254-
const result: TranspileOutput = this._transpileOutput(fileContent, fileName)
284+
assertCompilerOptionsWithJestTransformMode(this._initialCompilerOptions, isEsmMode, this._logger)
285+
286+
const result = this._transpileOutput(fileContent, fileName)
255287
if (result.diagnostics && this.configSet.shouldReportDiagnostics(fileName)) {
256288
this.configSet.raiseDiagnostics(result.diagnostics, fileName, this._logger)
257289
}
@@ -263,11 +295,20 @@ export class TsCompiler implements TsCompilerInstance {
263295
}
264296

265297
protected _transpileOutput(fileContent: string, fileName: string): TranspileOutput {
266-
return this._ts.transpileModule(fileContent, {
298+
if (shouldUseNativeTsTranspile(this._initialCompilerOptions)) {
299+
return this._ts.transpileModule(fileContent, {
300+
fileName,
301+
transformers: this._makeTransformers(this.configSet.resolvedTransformers),
302+
compilerOptions: this._compilerOptions,
303+
reportDiagnostics: this.configSet.shouldReportDiagnostics(fileName),
304+
})
305+
}
306+
307+
return tsTranspileModule(fileContent, {
267308
fileName,
268309
transformers: this._makeTransformers(this.configSet.resolvedTransformers),
269-
compilerOptions: this._compilerOptions,
270-
reportDiagnostics: this.configSet.shouldReportDiagnostics(fileName),
310+
compilerOptions: this._initialCompilerOptions,
311+
reportDiagnostics: fileName ? this.configSet.shouldReportDiagnostics(fileName) : false,
271312
})
272313
}
273314

‎src/legacy/config/config-set.ts

+2-12
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { TsCompilerInstance } from '../../types'
3535
import { rootLogger, stringify } from '../../utils'
3636
import { backportJestConfig } from '../../utils/backports'
3737
import { importer } from '../../utils/importer'
38-
import { Deprecations, Errors, ImportReasons, interpolate } from '../../utils/messages'
38+
import { Deprecations, Errors, ImportReasons, interpolate, TsJestDiagnosticCodes } from '../../utils/messages'
3939
import { normalizeSlashes } from '../../utils/normalize-slashes'
4040
import { sha1 } from '../../utils/sha1'
4141
import { TSError } from '../../utils/ts-error'
@@ -66,16 +66,6 @@ export const IGNORE_DIAGNOSTIC_CODES = [
6666
*/
6767
export const TS_JEST_OUT_DIR = '$$ts-jest$$'
6868

69-
/**
70-
* @internal
71-
*/
72-
// WARNING: DO NOT CHANGE THE ORDER OF CODE NAMES!
73-
// ONLY APPEND IF YOU NEED TO ADD SOME
74-
const enum DiagnosticCodes {
75-
TsJest = 151000,
76-
ConfigModuleOption,
77-
}
78-
7969
const normalizeRegex = (pattern: string | RegExp | undefined): string | undefined =>
8070
pattern ? (typeof pattern === 'string' ? pattern : pattern.source) : undefined
8171

@@ -500,7 +490,7 @@ export class ConfigSet {
500490
!(finalOptions.esModuleInterop || finalOptions.allowSyntheticDefaultImports)
501491
) {
502492
result.errors.push({
503-
code: DiagnosticCodes.ConfigModuleOption,
493+
code: TsJestDiagnosticCodes.ConfigModuleOption,
504494
messageText: Errors.ConfigNoModuleInterop,
505495
category: this.compilerModule.DiagnosticCategory.Message,
506496
file: undefined,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import path from 'node:path'
2+
3+
import { vol } from 'memfs'
4+
import type { PackageJson } from 'type-fest'
5+
import ts from 'typescript'
6+
7+
import { workspaceRoot } from '../../__helpers__/workspace-root'
8+
9+
import { tsTranspileModule } from './transpile-module'
10+
11+
jest.mock('fs', () => {
12+
const fsImpl = jest.requireActual('memfs').fs
13+
14+
return {
15+
...fsImpl,
16+
readFileSync: (path: string) => {
17+
if (path.includes('.ts-jest-digest')) {
18+
return 'ee2e176d55f97f10ef48a41f4e4089940db5d10f'
19+
}
20+
21+
return fsImpl.readFileSync(path)
22+
},
23+
}
24+
})
25+
jest.mock('node:fs', () => {
26+
const fsImpl = jest.requireActual('memfs').fs
27+
28+
return {
29+
...fsImpl,
30+
readFileSync: (path: string) => {
31+
if (path.includes('.ts-jest-digest')) {
32+
return 'ee2e176d55f97f10ef48a41f4e4089940db5d10f'
33+
}
34+
35+
return fsImpl.readFileSync(path)
36+
},
37+
}
38+
})
39+
40+
function dedent(strings: TemplateStringsArray, ...values: unknown[]) {
41+
let joinedString = ''
42+
for (let i = 0; i < values.length; i++) {
43+
joinedString += `${strings[i]}${values[i]}`
44+
}
45+
joinedString += strings[strings.length - 1]
46+
47+
return omitLeadingWhitespace(joinedString)
48+
}
49+
50+
function omitLeadingWhitespace(text: string): string {
51+
return text.replace(/^\s+/gm, '')
52+
}
53+
54+
describe('transpileModules', () => {
55+
describe('with modern Node resolution', () => {
56+
const esmModernNodeFilePath = path.join(workspaceRoot, 'esm-node-modern', 'foo.ts')
57+
const mtsFilePath = path.join(workspaceRoot, 'esm-node-modern', 'foo.mts')
58+
const cjsModernNodeFilePath = path.join(workspaceRoot, 'cjs-node-modern', 'foo.ts')
59+
const ctsFilePath = path.join(workspaceRoot, 'esm-node-modern', 'foo.cts')
60+
vol.fromJSON(
61+
{
62+
'./esm-node-modern/package.json': JSON.stringify({
63+
name: 'test-package-1',
64+
type: 'module',
65+
} as PackageJson),
66+
'./esm-node-modern/foo.ts': `
67+
import { foo } from 'foo';
68+
69+
console.log(foo);
70+
`,
71+
'./esm-node-modern/foo.mts': `
72+
import { foo } from 'foo';
73+
74+
console.log(foo);
75+
`,
76+
'./cjs-node-modern/package.json': JSON.stringify({
77+
name: 'test-package-1',
78+
type: 'commonjs',
79+
} as PackageJson),
80+
'./cjs-node-modern/foo.ts': `
81+
import { foo } from 'foo';
82+
83+
console.log(foo);
84+
`,
85+
'./esm-node-modern/foo.cts': `
86+
import { foo } from 'foo';
87+
88+
console.log(foo);
89+
`,
90+
},
91+
workspaceRoot,
92+
)
93+
94+
it.each([
95+
{
96+
module: ts.ModuleKind.Node16,
97+
moduleResolution: ts.ModuleResolutionKind.Node16,
98+
},
99+
{
100+
module: ts.ModuleKind.NodeNext,
101+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
102+
},
103+
])('should emit CJS code with "type: commonjs" in package.json', ({ module, moduleResolution }) => {
104+
const result = tsTranspileModule(vol.readFileSync(cjsModernNodeFilePath, 'utf-8').toString(), {
105+
fileName: cjsModernNodeFilePath,
106+
compilerOptions: {
107+
module,
108+
target: ts.ScriptTarget.ESNext,
109+
verbatimModuleSyntax: true,
110+
moduleResolution,
111+
},
112+
})
113+
114+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
115+
const foo_1 = require("foo");
116+
`)
117+
})
118+
119+
it.each([
120+
{
121+
module: ts.ModuleKind.Node16,
122+
moduleResolution: ts.ModuleResolutionKind.Node16,
123+
},
124+
{
125+
module: ts.ModuleKind.NodeNext,
126+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
127+
},
128+
])('should emit ESM code with "type: module" in package.json', ({ module, moduleResolution }) => {
129+
const result = tsTranspileModule(vol.readFileSync(esmModernNodeFilePath, 'utf-8').toString(), {
130+
fileName: esmModernNodeFilePath,
131+
compilerOptions: {
132+
module,
133+
target: ts.ScriptTarget.ESNext,
134+
moduleResolution,
135+
},
136+
})
137+
138+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
139+
import { foo } from 'foo';
140+
`)
141+
})
142+
143+
it('should emit ESM code with .mts extension', () => {
144+
const result = tsTranspileModule(vol.readFileSync(mtsFilePath, 'utf-8').toString(), {
145+
fileName: mtsFilePath,
146+
compilerOptions: {
147+
target: ts.ScriptTarget.ESNext,
148+
},
149+
})
150+
151+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
152+
import { foo } from 'foo';
153+
`)
154+
})
155+
156+
it('should emit CJS code with .cts extension', () => {
157+
const result = tsTranspileModule(vol.readFileSync(ctsFilePath, 'utf-8').toString(), {
158+
fileName: ctsFilePath,
159+
compilerOptions: {
160+
target: ts.ScriptTarget.ESNext,
161+
},
162+
})
163+
164+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
165+
import { foo } from 'foo';
166+
`)
167+
})
168+
})
169+
170+
describe('with classic CommonJS module and ES module kind', () => {
171+
vol.fromJSON(
172+
{
173+
'./foo.ts': `
174+
import { foo } from 'foo';
175+
176+
console.log(foo);
177+
`,
178+
},
179+
workspaceRoot,
180+
)
181+
182+
it('should emit CJS code with module kind set to CommonJS', () => {
183+
const filePath = path.join(workspaceRoot, 'foo.ts')
184+
const result = tsTranspileModule(vol.readFileSync(filePath, 'utf-8').toString(), {
185+
fileName: filePath,
186+
compilerOptions: {
187+
module: ts.ModuleKind.CommonJS,
188+
target: ts.ScriptTarget.ESNext,
189+
},
190+
})
191+
192+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
193+
const foo_1 = require("foo");
194+
`)
195+
})
196+
197+
it('should emit ESM code with module kind set to one of ES module value', () => {
198+
const filePath = path.join(workspaceRoot, 'foo.ts')
199+
const result = tsTranspileModule(vol.readFileSync(filePath, 'utf-8').toString(), {
200+
fileName: filePath,
201+
compilerOptions: {
202+
module: ts.ModuleKind.ES2022,
203+
target: ts.ScriptTarget.ESNext,
204+
},
205+
})
206+
207+
expect(omitLeadingWhitespace(result.outputText)).toContain(dedent`
208+
import { foo } from 'foo';
209+
`)
210+
})
211+
})
212+
213+
describe('with diagnostics', () => {
214+
const testFilePath = path.join(workspaceRoot, 'foo.ts')
215+
vol.fromJSON(
216+
{
217+
'./foo.ts': `
218+
import { foo } from 'foo';
219+
220+
console.log(foo);
221+
`,
222+
},
223+
workspaceRoot,
224+
)
225+
226+
it('should return diagnostics for invalid combination of compiler options', () => {
227+
const result = tsTranspileModule(vol.readFileSync(testFilePath, 'utf-8').toString(), {
228+
fileName: testFilePath,
229+
compilerOptions: {
230+
module: ts.ModuleKind.Node16,
231+
moduleResolution: ts.ModuleResolutionKind.Classic,
232+
},
233+
})
234+
235+
expect(result.diagnostics?.[0].messageText).toBeTruthy()
236+
})
237+
})
238+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import path from 'node:path'
2+
3+
import ts from 'typescript'
4+
5+
import { TsJestDiagnosticCodes } from '../../utils/messages'
6+
7+
const barebonesLibContent = `/// <reference no-default-lib="true"/>
8+
interface Boolean {}
9+
interface Function {}
10+
interface CallableFunction {}
11+
interface NewableFunction {}
12+
interface IArguments {}
13+
interface Number {}
14+
interface Object {}
15+
interface RegExp {}
16+
interface String {}
17+
interface Array<T> { length: number; [n: number]: T; }
18+
interface SymbolConstructor {
19+
(desc?: string | number): symbol;
20+
for(name: string): symbol;
21+
readonly toStringTag: symbol;
22+
}
23+
declare var Symbol: SymbolConstructor;
24+
interface Symbol {
25+
readonly [Symbol.toStringTag]: string;
26+
}`
27+
const barebonesLibName = 'lib.d.ts'
28+
let barebonesLibSourceFile: ts.SourceFile | undefined
29+
30+
const carriageReturnLineFeed = '\r\n'
31+
const lineFeed = '\n'
32+
function getNewLineCharacter(options: ts.CompilerOptions): string {
33+
switch (options.newLine) {
34+
case ts.NewLineKind.CarriageReturnLineFeed:
35+
return carriageReturnLineFeed
36+
case ts.NewLineKind.LineFeed:
37+
default:
38+
return lineFeed
39+
}
40+
}
41+
42+
type ExtendedTsTranspileModuleFn = (input: string, transpileOptions: ts.TranspileOptions) => ts.TranspileOutput
43+
44+
/**
45+
* Copy source code of {@link ts.transpileModule} from {@link https://github.com/microsoft/TypeScript/blob/main/src/services/transpile.ts}
46+
* with extra modifications:
47+
* - Remove generation of declaration files
48+
* - Allow using custom AST transformers with the internal created {@link Program}
49+
*/
50+
const transpileWorker: ExtendedTsTranspileModuleFn = (input, transpileOptions) => {
51+
barebonesLibSourceFile ??= ts.createSourceFile(barebonesLibName, barebonesLibContent, {
52+
languageVersion: ts.ScriptTarget.Latest,
53+
})
54+
55+
const diagnostics: ts.Diagnostic[] = []
56+
57+
const options: ts.CompilerOptions = transpileOptions.compilerOptions
58+
? // @ts-expect-error internal TypeScript API
59+
ts.fixupCompilerOptions(transpileOptions.compilerOptions, diagnostics)
60+
: {}
61+
62+
// mix in default options
63+
const defaultOptions = ts.getDefaultCompilerOptions()
64+
for (const key in defaultOptions) {
65+
if (Object.hasOwn(defaultOptions, key) && options[key] === undefined) {
66+
options[key] = defaultOptions[key]
67+
}
68+
}
69+
70+
// @ts-expect-error internal TypeScript API
71+
for (const option of ts.transpileOptionValueCompilerOptions) {
72+
// Do not set redundant config options if `verbatimModuleSyntax` was supplied.
73+
if (options.verbatimModuleSyntax && new Set(['isolatedModules']).has(option.name)) {
74+
continue
75+
}
76+
77+
options[option.name] = option.transpileOptionValue
78+
}
79+
80+
// transpileModule does not write anything to disk so there is no need to verify that there are no conflicts between input and output paths.
81+
options.suppressOutputPathCheck = true
82+
83+
// Filename can be non-ts file.
84+
options.allowNonTsExtensions = true
85+
options.declaration = false
86+
options.declarationMap = false
87+
88+
const newLine = getNewLineCharacter(options)
89+
// if jsx is specified then treat file as .tsx
90+
const inputFileName =
91+
transpileOptions.fileName ?? (transpileOptions.compilerOptions?.jsx ? 'module.tsx' : 'module.ts')
92+
// Create a compilerHost object to allow the compiler to read and write files
93+
const compilerHost: ts.CompilerHost = {
94+
getSourceFile: (fileName) => {
95+
// @ts-expect-error internal TypeScript API
96+
if (fileName === ts.normalizePath(inputFileName)) {
97+
return sourceFile
98+
}
99+
100+
// @ts-expect-error internal TypeScript API
101+
return fileName === ts.normalizePath(barebonesLibName) ? barebonesLibSourceFile : undefined
102+
},
103+
writeFile: (name, text) => {
104+
if (path.extname(name) === '.map') {
105+
sourceMapText = text
106+
} else {
107+
outputText = text
108+
}
109+
},
110+
getDefaultLibFileName: () => barebonesLibName,
111+
useCaseSensitiveFileNames: () => false,
112+
getCanonicalFileName: (fileName) => fileName,
113+
getCurrentDirectory: () => '',
114+
getNewLine: () => newLine,
115+
fileExists: (fileName) => {
116+
return fileName.endsWith('package.json') ? ts.sys.fileExists(fileName) : fileName === inputFileName
117+
},
118+
readFile: (fileName) => {
119+
return fileName.endsWith('package.json') ? ts.sys.readFile(fileName) : ''
120+
},
121+
directoryExists: () => true,
122+
getDirectories: () => [],
123+
}
124+
125+
const sourceFile = ts.createSourceFile(inputFileName, input, {
126+
languageVersion: options.target ?? ts.ScriptTarget.ESNext,
127+
impliedNodeFormat: ts.getImpliedNodeFormatForFile(
128+
inputFileName,
129+
/*packageJsonInfoCache*/ undefined,
130+
compilerHost,
131+
options,
132+
),
133+
// @ts-expect-error internal TypeScript API
134+
setExternalModuleIndicator: ts.getSetExternalModuleIndicator(options),
135+
jsDocParsingMode: transpileOptions.jsDocParsingMode ?? ts.JSDocParsingMode.ParseAll,
136+
})
137+
if (transpileOptions.moduleName) {
138+
sourceFile.moduleName = transpileOptions.moduleName
139+
}
140+
141+
if (transpileOptions.renamedDependencies) {
142+
// @ts-expect-error internal TypeScript API
143+
sourceFile.renamedDependencies = new Map(Object.entries(transpileOptions.renamedDependencies))
144+
}
145+
146+
// Output
147+
let outputText: string | undefined
148+
let sourceMapText: string | undefined
149+
const inputs = [inputFileName]
150+
const program = ts.createProgram(inputs, options, compilerHost)
151+
152+
if (transpileOptions.reportDiagnostics) {
153+
diagnostics.push(...program.getSyntacticDiagnostics(sourceFile))
154+
}
155+
156+
diagnostics.push(...program.getOptionsDiagnostics())
157+
158+
// Emit
159+
const result = program.emit(
160+
/*targetSourceFile*/ undefined,
161+
/*writeFile*/ undefined,
162+
/*cancellationToken*/ undefined,
163+
/*emitOnlyDtsFiles*/ undefined,
164+
transpileOptions.transformers,
165+
)
166+
167+
diagnostics.push(...result.diagnostics)
168+
169+
if (outputText === undefined) {
170+
diagnostics.push({
171+
category: ts.DiagnosticCategory.Error,
172+
code: TsJestDiagnosticCodes.Generic,
173+
messageText: 'No output generated',
174+
file: sourceFile,
175+
start: 0,
176+
length: 0,
177+
})
178+
}
179+
180+
return { outputText: outputText ?? '', diagnostics, sourceMapText }
181+
}
182+
183+
export const tsTranspileModule = transpileWorker

‎src/utils/messages.ts

+7
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const enum Errors {
1919
CannotProcessFile = "Unable to process '{{file}}', please make sure that `outDir` in your tsconfig is neither `''` or `'.'`. You can also configure Jest config option `transformIgnorePatterns` to inform `ts-jest` to transform {{file}}",
2020
MissingTransformerName = 'The AST transformer {{file}} must have an `export const name = <your_transformer_name>`',
2121
MissingTransformerVersion = 'The AST transformer {{file}} must have an `export const version = <your_transformer_version>`',
22+
InvalidModuleKindForEsm = 'The current compiler option "module" value is not suitable for Jest ESM mode. Please either use ES module kinds or Node16/NodeNext module kinds with "type: module" in package.json',
2223
}
2324

2425
/**
@@ -27,6 +28,7 @@ export const enum Errors {
2728
export const enum Helps {
2829
FixMissingModule = '{{label}}: `npm i -D {{module}}` (or `yarn add --dev {{module}}`)',
2930
MigrateConfigUsingCLI = 'Your Jest configuration is outdated. Use the CLI to help migrating it: ts-jest config:migrate <config-file>.',
31+
UsingModernNodeResolution = 'Using hybrid module kind (Node16/18/Next) is only supported in "isolatedModules: true". Please set "isolatedModules: true" in your tsconfig.json.',
3032
}
3133

3234
/**
@@ -64,3 +66,8 @@ export function interpolate(msg: string, vars: Record<string, any> = {}): string
6466
// eslint-disable-next-line no-useless-escape
6567
return msg.replace(/\{\{([^\}]+)\}\}/g, (_, key) => (key in vars ? vars[key] : _))
6668
}
69+
70+
export const TsJestDiagnosticCodes = {
71+
Generic: 151000,
72+
ConfigModuleOption: 151001,
73+
} as const

0 commit comments

Comments
 (0)
Please sign in to comment.