Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for using native Node ESM loader #13521

Closed
wants to merge 3 commits into from
Closed
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
17 changes: 14 additions & 3 deletions packages/jest-config/src/readConfigFileAndSetRootDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,27 @@ import {
export default async function readConfigFileAndSetRootDir(
configPath: string,
): Promise<Config.InitialOptions> {
const hasLoader = process.env.NODE_OPTIONS?.includes('--loader');
const isTS = configPath.endsWith(JEST_CONFIG_EXT_TS);
const isJSON = configPath.endsWith(JEST_CONFIG_EXT_JSON);
let configObject;

try {
if (isTS) {
configObject = await loadTSConfigFile(configPath);
} else if (isJSON) {
if (isJSON) {
const fileContent = fs.readFileSync(configPath, 'utf8');
configObject = parseJson(stripJsonComments(fileContent), configPath);
} else if (hasLoader) {
const importedModule = await import(configPath);

if (!importedModule.default) {
throw new Error(
`Jest: Failed to load ESM at ${configPath} - did you use a default export?`,
);
}

configObject = importedModule.default;
} else if (isTS) {
configObject = await loadTSConfigFile(configPath);
} else {
configObject = await requireOrImportModule<any>(configPath);
}
Expand Down
9 changes: 7 additions & 2 deletions packages/jest-runner/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
} from '@jest/console';
import type {JestEnvironment} from '@jest/environment';
import type {TestFileEvent, TestResult} from '@jest/test-result';
import {createScriptTransformer} from '@jest/transform';
import {
createNodeEsmLoaderTransformer,
createScriptTransformer,
} from '@jest/transform';
import type {Config} from '@jest/types';
import * as docblock from 'jest-docblock';
import LeakDetector from 'jest-leak-detector';
Expand Down Expand Up @@ -104,7 +107,9 @@ async function runTestInternal(
}

const cacheFS = new Map([[path, testSource]]);
const transformer = await createScriptTransformer(projectConfig, cacheFS);
const transformer =
(await createNodeEsmLoaderTransformer()) ??
(await createScriptTransformer(projectConfig, cacheFS));

const TestEnvironment: typeof JestEnvironment =
await transformer.requireAndTranspileModule(testEnvironment);
Expand Down
128 changes: 128 additions & 0 deletions packages/jest-transform/src/NodeEsmLoaderTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {pathToFileURL} from 'url';
import {requireOrImportModule} from 'jest-util';
import type {
Options,
ReducedTransformOptions,
RequireAndTranspileModuleOptions,
} from './types';
import type {ScriptTransformer, TransformResult} from '.';

// https://nodejs.org/api/esm.html#loadurl-context-nextload
interface NodeEsmLoader {
load(
url: string,
context: {
format: string;
importAssertions: Record<string, string>;
},
defaultLoad: NodeEsmLoader['load'],
): Promise<{
format: string;
source: string | ArrayBuffer | SharedArrayBuffer | Uint8Array;
}>;
}

const reLoader =
/--loader\s+((@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(\/\S+)?)/;

class NodeEsmLoaderTransformer implements ScriptTransformer {
private readonly _loader: NodeEsmLoader;

constructor(loader: NodeEsmLoader) {
this._loader = loader;
}

transformSource(
_: string,
__: string,
___: ReducedTransformOptions,
): TransformResult {
throw new Error(
'Synchrnous transforms are not supported when using --loader',
);
}

async transformSourceAsync(
_: string,
__: string,
___: ReducedTransformOptions,
): Promise<TransformResult> {
return Promise.reject(
new Error(
'`transformSourceAsync` should not be called when using --loader',
),
);
}

transform(_: string, __: Options, ___?: string): TransformResult {
throw new Error(
'Synchrnous transforms are not supported when using --loader',
);
}

transformJson(_: string, __: any, fileSource: string): string {
return fileSource;
}

async transformAsync(
filename: string,
_: unknown,
fileSource: string,
): Promise<TransformResult> {
const url = pathToFileURL(filename).href;

const result = await this._loader.load(
url,
{format: 'module', importAssertions: {}},
async () => {
return Promise.resolve({format: 'module', source: fileSource});
},
);

return {
code:
typeof result.source === 'string'
? result.source
: new TextDecoder().decode(result.source),
originalCode: fileSource,
sourceMapPath: null,
};
}

async requireAndTranspileModule<ModuleType = unknown>(
moduleName: string,
callback?: (module: ModuleType) => void | Promise<void>,
options?: RequireAndTranspileModuleOptions,
): Promise<ModuleType> {
if (callback) {
throw new Error(
'`requireAndTranspileModule` with a callback should not be called when using --loader',
);
}

if (options) {
throw new Error(
'`requireAndTranspileModule` with options should not be called when using --loader',
);
}

return requireOrImportModule(moduleName);
}
}

export async function createNodeEsmLoaderTransformer(): Promise<ScriptTransformer | null> {
const match = reLoader.exec(process.env.NODE_OPTIONS ?? '');
if (match == null) return null;

const loaderName = match[1];
const loader = (await import(loaderName)) as NodeEsmLoader;

return new NodeEsmLoaderTransformer(loader);
}
9 changes: 8 additions & 1 deletion packages/jest-transform/src/ScriptTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,14 @@ function assertSyncTransformer(
);
}

export type TransformerType = ScriptTransformer;
export interface TransformerType {
requireAndTranspileModule: ScriptTransformer['requireAndTranspileModule'];
transform: ScriptTransformer['transform'];
transformAsync: ScriptTransformer['transformAsync'];
transformJson: ScriptTransformer['transformJson'];
transformSource: ScriptTransformer['transformSource'];
transformSourceAsync: ScriptTransformer['transformSourceAsync'];
}

export async function createScriptTransformer(
config: Config.ProjectConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2066,7 +2066,9 @@ describe('ScriptTransformer', () => {
});

// @ts-expect-error - private property
expect(Array.from(scriptTransformer._transformCache.entries())).toEqual([
const cache = scriptTransformer._transformCache as Map<unknown, unknown>;

expect(Array.from(cache.entries())).toEqual([
['\\.js$test_preprocessor', expect.any(Object)],
]);
});
Expand Down
1 change: 1 addition & 0 deletions packages/jest-transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/

export {createNodeEsmLoaderTransformer} from './NodeEsmLoaderTransformer';
export {
createScriptTransformer,
createTranspilingRequire,
Expand Down