Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- `[jest-reporters]` Fix issue where console output not displayed for GHA reporter even with `silent: false` option ([#15864](https://github.com/jestjs/jest/pull/15864))
- `[jest-runtime]` Fix issue where user cannot utilize dynamic import despite specifying `--experimental-vm-modules` Node option ([#15842](https://github.com/jestjs/jest/pull/15842))
- `[jest-test-sequencer]` Fix issue where failed tests due to compilation errors not getting re-executed even with `--onlyFailures` CLI option ([#15851](https://github.com/jestjs/jest/pull/15851))
- `[jest-runner, jest-transformer]` support custom test environment written in ESM with `TypeScript` extensions ([#15885](https://github.com/jestjs/jest/pull/15885)), fixes downstream [issue](https://github.com/kulshekhar/ts-jest/issues/4195)

### Chore & Maintenance

Expand Down
43 changes: 39 additions & 4 deletions e2e/__tests__/testEnvironmentEsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,46 @@
*/

import {resolve} from 'path';
import runJest from '../runJest';
import {json as runJestJson} from '../runJest';

it('support test environment written in ESM', () => {
const DIR = resolve(__dirname, '../test-environment-esm');
const {exitCode} = runJest(DIR);
const DIR = resolve(__dirname, '../test-environment-esm');

it('support test environment written in ESM with `.ts` extension', () => {
const {exitCode, json} = runJestJson(DIR, ['testUsingTsEnv.test.js'], {
nodeOptions: '--experimental-vm-modules --no-warnings',
});

expect(exitCode).toBe(0);
expect(json.numTotalTests).toBe(1);
expect(json.numPassedTests).toBe(1);
});

it('support test environment written in ESM with `.mts` extension', () => {
const {exitCode, json} = runJestJson(DIR, ['testUsingMtsEnv.test.js'], {
nodeOptions: '--experimental-vm-modules --no-warnings',
});

expect(exitCode).toBe(0);
expect(json.numTotalTests).toBe(1);
expect(json.numPassedTests).toBe(1);
});

it('support test environment written in ESM with `.js` extension', () => {
const {exitCode, json} = runJestJson(DIR, ['testUsingJsEnv.test.js'], {
nodeOptions: '--experimental-vm-modules --no-warnings',
});

expect(exitCode).toBe(0);
expect(json.numTotalTests).toBe(1);
expect(json.numPassedTests).toBe(1);
});

it('support test environment written in ESM with `.mjs` extension', () => {
const {exitCode, json} = runJestJson(DIR, ['testUsingMjsEnv.test.js'], {
nodeOptions: '--experimental-vm-modules --no-warnings',
});

expect(exitCode).toBe(0);
expect(json.numTotalTests).toBe(1);
expect(json.numPassedTests).toBe(1);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment <rootDir>/env-3.js
*/
'use strict';

test('dummy', () => {
test('should pass', () => {
expect(globalThis.someVar).toBe(42);
});
13 changes: 13 additions & 0 deletions e2e/test-environment-esm/__tests__/testUsingMjsEnv.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment <rootDir>/env-4.mjs
*/
'use strict';

test('should pass', () => {
expect(globalThis.someVar).toBe(42);
});
13 changes: 13 additions & 0 deletions e2e/test-environment-esm/__tests__/testUsingMtsEnv.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment <rootDir>/env-1.mts
*/
'use strict';

test('should pass', () => {
expect(globalThis.someVar).toBe(42);
});
13 changes: 13 additions & 0 deletions e2e/test-environment-esm/__tests__/testUsingTsEnv.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment <rootDir>/env-2.ts
*/
'use strict';

test('should pass', () => {
expect(globalThis.someVar).toBe(42);
});
13 changes: 13 additions & 0 deletions e2e/test-environment-esm/babel.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};
26 changes: 26 additions & 0 deletions e2e/test-environment-esm/env-1.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import type {
EnvironmentContext,
JestEnvironmentConfig,
} from '@jest/environment';
import {TestEnvironment} from 'jest-environment-node';

declare global {
interface ImportMeta {
someVar: number;
}
}

export default class Env extends TestEnvironment {
constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {
super(config, context);
import.meta.someVar = 42;
this.global.someVar = import.meta.someVar;
}
}
26 changes: 26 additions & 0 deletions e2e/test-environment-esm/env-2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import type {
EnvironmentContext,
JestEnvironmentConfig,
} from '@jest/environment';
import {TestEnvironment} from 'jest-environment-node';

declare global {
interface ImportMeta {
someVar: number;
}
}

export default class Env extends TestEnvironment {
constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {
super(config, context);
import.meta.someVar = 42;
this.global.someVar = import.meta.someVar;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
import {TestEnvironment} from 'jest-environment-node';

export default class Env extends TestEnvironment {
constructor(...args) {
super(...args);
this.global.someVar = 42;
constructor(config, context) {
super(config, context);
import.meta.someVar = 42;
this.global.someVar = import.meta.someVar;
}
}
16 changes: 16 additions & 0 deletions e2e/test-environment-esm/env-4.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {TestEnvironment} from 'jest-environment-node';

export default class Env extends TestEnvironment {
constructor(config, context) {
super(config, context);
import.meta.someVar = 42;
this.global.someVar = import.meta.someVar;
}
}
14 changes: 12 additions & 2 deletions e2e/test-environment-esm/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
{
"type": "module",
"jest": {
"testEnvironment": "<rootDir>/EnvESM.js",
"transform": {}
"transform": {
"^.+\\.(ts|mts)$": "babel-jest"
},
"extensionsToTreatAsEsm": [
".ts",
".mts"
]
},
"dependencies": {
"@babel/preset-env": "^7.0.0",
"@babel/preset-typescript": "^7.0.0",
"jest-environment-node": "file:../../packages/jest-environment-node"
}
}
14 changes: 11 additions & 3 deletions packages/jest-runner/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import type {Config} from '@jest/types';
import * as docblock from 'jest-docblock';
import LeakDetector from 'jest-leak-detector';
import {formatExecError} from 'jest-message-util';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import Resolver, {resolveTestEnvironment} from 'jest-resolve';
import type RuntimeClass from 'jest-runtime';
import {ErrorWithStack, interopRequireDefault, setGlobal} from 'jest-util';
Expand Down Expand Up @@ -109,9 +108,18 @@ async function runTestInternal(

const cacheFS = new Map([[path, testSource]]);
const transformer = await createScriptTransformer(projectConfig, cacheFS);
const shouldLoadTestEnvAsEsm = Resolver.unstable_shouldLoadAsEsm(
testEnvironment,
projectConfig.extensionsToTreatAsEsm,
);

const TestEnvironment: typeof JestEnvironment =
await transformer.requireAndTranspileModule(testEnvironment);
const TestEnvironment: typeof JestEnvironment = shouldLoadTestEnvAsEsm
? await transformer.importAndTranspileModule(testEnvironment, undefined, {
collectCoverage: globalConfig.collectCoverage,
collectCoverageFrom: globalConfig.collectCoverageFrom,
coverageProvider: globalConfig.coverageProvider,
})
: await transformer.requireAndTranspileModule(testEnvironment);
const testFramework: TestFramework =
await transformer.requireAndTranspileModule(
process.env.JEST_JASMINE === '1'
Expand Down
83 changes: 81 additions & 2 deletions packages/jest-transform/src/ScriptTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/

import {createHash} from 'crypto';
import * as path from 'path';
import {createHash} from 'node:crypto';
import * as path from 'node:path';
import {transformSync as babelTransform} from '@babel/core';
// @ts-expect-error: should just be `require.resolve`, but the tests mess that up
import babelPluginIstanbul from 'babel-plugin-istanbul';
Expand Down Expand Up @@ -35,6 +35,7 @@ import {
import shouldInstrument from './shouldInstrument';
import type {
FixedRawSourceMap,
ImportAndTranspileModuleOptions,
Options,
ReducedTransformOptions,
RequireAndTranspileModuleOptions,
Expand Down Expand Up @@ -82,6 +83,32 @@ function isTransformerFactory<X extends Transformer>(
return typeof (t as TransformerFactory<X>).createTransformer === 'function';
}

async function loadEsmModule<ModuleType = unknown>(
filePath: string,
callback?: (module: ModuleType) => void | Promise<void>,
): Promise<ModuleType> {
const moduleType: object = await requireOrImportModule(filePath);
const esmModule = (
'default' in moduleType ? moduleType.default : moduleType
) as ModuleType;

if (callback) {
const cbResult = callback(esmModule);
if (isPromise(cbResult)) {
await cbResult;
}
}

return esmModule;
}

function getEsmCacheExtension(ext: string): string {
if (ext === '.mts' || ext === '.mtsx') return '.mjs';
if (ext === '.ts' || ext === '.tsx') return '.js';

return ext;
}

class ScriptTransformer {
private readonly _cache: ProjectCache;
private readonly _transformCache = new Map<
Expand Down Expand Up @@ -843,6 +870,58 @@ class ScriptTransformer {

return this._config.transform.length > 0 && !isIgnored;
}

/**
* Transforms and imports an ES module.
* Unlike {@link requireAndTranspileModule} this method first attempts to import
* the file via {@link requireOrImportModule} and fallback to transform files and
* then imports it again.
*
* The fallback case is necessary for TypeScript files in ESM format
*/
async importAndTranspileModule<ModuleType = unknown>(
moduleName: string,
callback?: (module: ModuleType) => void | Promise<void>,
options?: ImportAndTranspileModuleOptions,
): Promise<ModuleType> {
const transformOptions: Options = {
collectCoverage: options?.collectCoverage ?? false,
collectCoverageFrom: options?.collectCoverageFrom ?? [],
coverageProvider: options?.coverageProvider ?? 'v8',
isInternalModule: false,
supportsDynamicImport: true,
supportsExportNamespaceFrom: true,
supportsStaticESM: true,
supportsTopLevelAwait: true,
...options,
};
try {
return await loadEsmModule(moduleName, callback);
} catch {
const transformResult = await this.transformAsync(
moduleName,
transformOptions,
);
const fileExt = path.extname(moduleName);
const targetExt = getEsmCacheExtension(fileExt);
const tempTransformedPath = moduleName.replace(
fileExt,
`.jest-esm-transform-${Date.now()}${targetExt}`,
);

fs.writeFileSync(tempTransformedPath, transformResult.code, 'utf8');

try {
return await loadEsmModule(tempTransformedPath, callback);
} finally {
try {
fs.unlinkSync(tempTransformedPath);
} catch {
// Ignore cleanup errors
}
}
}
}
}

// TODO: do we need to define the generics twice?
Expand Down
Loading
Loading