chore: re-implement ESM support (#16063)

This commit is contained in:
Simen Bekkhus
2026-05-01 10:28:24 +02:00
committed by GitHub
parent 67e6c947f5
commit 9f32f63b2e
38 changed files with 1564 additions and 177 deletions
+8
View File
@@ -177,6 +177,14 @@ jobs:
max_attempts: 3
retry_on: error
command: yarn jest-runtime-vm-modules-ci --max-workers ${{ steps.cpu-cores.outputs.count }}
- name: map coverage
run: node ./scripts/mapCoverage.mjs
if: always()
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
if: always()
with:
directory: ./coverage
token: ${{ secrets.CODECOV_TOKEN }}
test-coverage:
name: Node LTS on Ubuntu with coverage (${{ matrix.shard }})
+4
View File
@@ -13,6 +13,10 @@
- `[jest-runtime]` Load `.js` files with ESM syntax but no `"type":"module"` marker as native ESM ([#16050](https://github.com/jestjs/jest/pull/16050))
- `[jest-runtime]` Fix deadlocks and double-evaluation in concurrent ESM and wasm imports ([#16050](https://github.com/jestjs/jest/pull/16050))
### Chore & Maintenance
- `[jest-runtime]` Use synchronous `linkRequests` / `instantiate` for ESM linking on Node v24.9+ ([#16063](https://github.com/jestjs/jest/pull/16063))
## 30.3.0
### Features
+26
View File
@@ -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 {resolve} from 'path';
import {onNodeVersions} from '@jest/test-utils';
import runJest from '../runJest';
const DIR = resolve(__dirname, '../native-esm-sync-linker');
// `vm.SourceTextModule#linkRequests` and `instantiate` ship in Node v24.9.
// On older Node, the legacy async ESM path runs and is covered by the
// existing native-esm fixture.
onNodeVersions('>=24.9.0', () => {
test('sync linker handles diamond + cycle graph', () => {
const {exitCode, stderr} = runJest(DIR, ['sync-linker.test.js'], {
nodeOptions: '--experimental-vm-modules --no-warnings',
});
expect(stderr).toContain('1 passed');
expect(exitCode).toBe(0);
});
});
@@ -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 {fromA, valueB, valueC} from '../a.mjs';
import {peekA} from '../c.mjs';
test('diamond + cycle graph evaluates correctly', () => {
expect(fromA).toEqual({valueA: 'a', valueB: 'b', valueC: 'c'});
expect(valueB).toBe('b');
expect(valueC).toBe('c');
expect(peekA()).toBe('a');
});
+13
View File
@@ -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.
*/
import {valueB} from './b.mjs';
import {valueC} from './c.mjs';
export const valueA = 'a';
export const fromA = {valueA, valueB, valueC};
export {valueB, valueC};
+11
View File
@@ -0,0 +1,11 @@
/**
* 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 {valueC} from './c.mjs';
export const valueB = 'b';
export {valueC};
+12
View File
@@ -0,0 +1,12 @@
/**
* 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.
*/
// Cycle: c imports the lazy ref from a, exercised via getter only.
import * as a from './a.mjs';
export const valueC = 'c';
export const peekA = () => a.valueA;
+7
View File
@@ -0,0 +1,7 @@
{
"type": "module",
"jest": {
"testEnvironment": "node",
"transform": {}
}
}
+1
View File
@@ -814,6 +814,7 @@ const config = defineConfig(
// JS Syntax error
'{docs,website/versioned_docs/version-*}/ECMAScriptModules.md',
'{docs,website/versioned_docs/version-*}/JestObjectAPI.md',
'packages/jest-runtime/src/__tests__/test_esm_sync_graph_root/syntax-error.mjs',
// Bug? Uses TS syntax
'e2e/babel-plugin-jest-hoist/__tests__/integration.test.js',
+1 -1
View File
@@ -28,7 +28,7 @@ export default {
'packages/.*/build',
'packages/.*/tsconfig.*',
'packages/jest-runtime/src/__tests__/test_root.*',
'packages/jest-runtime/src/__tests__/test_esm_interop_root',
'packages/jest-runtime/src/__tests__/test_esm_*',
'website/.*',
'e2e/runtime-internal-module-registry/__mocks__',
'e2e/node-url-manual-mocks/__mocks__',
+1 -1
View File
@@ -100,7 +100,7 @@
"jest-jasmine": "JEST_JASMINE=1 yarn jest",
"jest-jasmine-ci": "yarn jest-jasmine --color --config jest.config.ci.mjs",
"jest-runtime-vm-modules": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" yarn jest packages/jest-runtime",
"jest-runtime-vm-modules-ci": "yarn jest-runtime-vm-modules --color --config jest.config.ci.mjs",
"jest-runtime-vm-modules-ci": "yarn jest-runtime-vm-modules --color --config jest.config.ci.mjs --coverage",
"jest-coverage": "yarn jest --coverage",
"lint": "eslint . --cache --ext js,jsx,cjs,mjs,ts,tsx,md",
"lint:prettier-script": "prettier . \"!**/*.{js,jsx,cjs,mjs,ts,tsx}\" --cache",
@@ -0,0 +1,264 @@
/**
* 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 * as path from 'path';
import {SourceTextModule, SyntheticModule} from 'vm';
const vmAvailable = typeof SyntheticModule === 'function';
const itVm = vmAvailable ? it : it.skip;
const syncCoreAvailable =
// @ts-expect-error - hasAsyncGraph is in Node v24.9+, not yet typed
typeof SourceTextModule?.prototype.hasAsyncGraph === 'function';
const itSyncOnly = syncCoreAvailable ? it : it.skip;
const ROOT_DIR = path.join(__dirname, 'test_esm_sync_graph_root');
const FROM = path.join(ROOT_DIR, 'test.js');
let createRuntime: (
filename: string,
config?: Record<string, unknown>,
) => Promise<any>;
describe('Runtime sync ESM graph', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
});
itVm('evaluates a diamond + cycle graph in correct order', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-diamond.mjs',
)) as any;
expect(m.namespace.fromA).toEqual({valueA: 'a', valueB: 'b', valueC: 'c'});
expect(m.namespace.valueB).toBe('b');
expect(m.namespace.valueC).toBe('c');
expect(m.namespace.peekA()).toBe('a');
});
itVm(
'caches modules so repeated imports return the same namespace',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const first = (await runtime.unstable_importModule(
FROM,
'./a.mjs',
)) as any;
const second = (await runtime.unstable_importModule(
FROM,
'./a.mjs',
)) as any;
expect(first.namespace).toBe(second.namespace);
},
);
itVm(
'falls back to async evaluate when the graph contains top-level await',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-tla.mjs',
)) as any;
expect(m.namespace.value).toBe('tla-value');
expect(m.namespace.wrapper).toBe('wrapper');
},
);
itVm('resolves data: URI specifiers in the sync graph', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-data-uri.mjs',
)) as any;
expect(m.namespace.dataValue).toBe(99);
});
itVm('resolves @jest/globals in the sync graph', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-jest-globals.mjs',
)) as any;
expect(m.namespace.hasJest).toBe(true);
});
itVm('decodes base64-encoded data: URI specifiers', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-data-uri-base64.mjs',
)) as any;
expect(m.namespace.base64Value).toBe('b64');
});
itVm('imports JSON files as ESM', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-json.mjs',
)) as any;
expect(m.namespace.data).toEqual({answer: 42, label: 'json'});
});
itVm('imports core node modules through the ESM graph', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-core.mjs',
)) as any;
expect(typeof m.namespace.EventEmitter).toBe('function');
expect(typeof m.namespace.nodePath.join).toBe('function');
});
itVm('exposes import.meta.url for the loaded module', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-meta.mjs',
)) as any;
expect(m.namespace.url).toBe(
`file://${path.join(ROOT_DIR, 'import-meta.mjs')}`,
);
});
itVm('pulls a CJS dependency into the sync ESM graph', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-cjs-dep.mjs',
)) as any;
expect(m.namespace.cjsValue).toBe('from-cjs');
});
itVm('imports a wasm module via data: URI', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-wasm.mjs',
)) as any;
// Empty wasm module → namespace exists but has no exports.
expect(m.namespace.wasmMod).toBeDefined();
expect(Object.keys(m.namespace.wasmMod)).toEqual([]);
});
itVm('treats a query suffix as a separate cache entry', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const plain = (await runtime.unstable_importModule(FROM, './a.mjs')) as any;
const queried = (await runtime.unstable_importModule(
FROM,
'./a.mjs?v=2',
)) as any;
// Different cache entries → different module instances, but same shape.
expect(queried.namespace).not.toBe(plain.namespace);
expect(queried.namespace.fromA).toEqual(plain.namespace.fromA);
});
itVm('supports dynamic import() from inside an ESM module', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const m = (await runtime.unstable_importModule(
FROM,
'./import-dynamic.mjs',
)) as any;
const fromA = await m.namespace.loadA();
expect(fromA).toEqual({valueA: 'a', valueB: 'b', valueC: 'c'});
});
});
describe('Runtime sync ESM graph — mocks and isolation', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
});
itVm('replaces a module with a sync mock factory', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
runtime.setModuleMock(FROM, './mock-target.mjs', () => ({
greeting: 'mocked-sync',
}));
const m = (await runtime.unstable_importModule(
FROM,
'./import-mock-target.mjs',
)) as any;
expect(m.namespace.greeting).toBe('mocked-sync');
});
itVm('replaces a module with an async mock factory', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
runtime.setModuleMock(FROM, './mock-target.mjs', async () => ({
greeting: 'mocked-async',
}));
const m = (await runtime.unstable_importModule(
FROM,
'./import-mock-target.mjs',
)) as any;
expect(m.namespace.greeting).toBe('mocked-async');
});
itVm('isolateModulesAsync gives a fresh ESM namespace', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const before = (await runtime.unstable_importModule(
FROM,
'./stateful.mjs',
)) as any;
before.namespace.increment();
before.namespace.increment();
expect(before.namespace.getCount()).toBe(2);
let isolatedCount: number | undefined;
await runtime.isolateModulesAsync(async () => {
const inside = (await runtime.unstable_importModule(
FROM,
'./stateful.mjs',
)) as any;
expect(inside.namespace).not.toBe(before.namespace);
isolatedCount = inside.namespace.getCount();
});
expect(isolatedCount).toBe(0);
// Outer registry survives the isolated block.
expect(before.namespace.getCount()).toBe(2);
});
});
describe('Runtime sync ESM graph — error surfacing', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
});
itVm('rejects when a specifier cannot be resolved', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
await expect(
runtime.unstable_importModule(FROM, './does-not-exist.mjs'),
).rejects.toThrow('Cannot find module');
});
itVm('surfaces errors thrown by a mock factory', async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
runtime.setModuleMock(FROM, './mock-target.mjs', () => {
throw new Error('factory boom');
});
await expect(
runtime.unstable_importModule(FROM, './import-throwing-mock.mjs'),
).rejects.toThrow('factory boom');
});
// Sync-core only: the legacy path leaks the SourceTextModule constructor
// SyntaxError as an unhandled rejection through `_fileTransformsMutex`,
// which crashes the process on Node v22. The sync core surfaces it cleanly.
itSyncOnly(
'rejects with a SyntaxError for ESM with parse errors',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
// Cross-realm: the SyntaxError comes from a vm sandbox, so match by name
// instead of `instanceof SyntaxError`.
await expect(
runtime.unstable_importModule(FROM, './syntax-error.mjs'),
).rejects.toMatchObject({name: 'SyntaxError'});
},
);
});
@@ -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.
*/
import {valueB} from './b.mjs';
import {valueC} from './c.mjs';
export const valueA = 'a';
export const fromA = {valueA, valueB, valueC};
export {valueB, valueC};
@@ -0,0 +1,11 @@
/**
* 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 {valueC} from './c.mjs';
export const valueB = 'b';
export {valueC};
@@ -0,0 +1,12 @@
/**
* 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.
*/
// Cycle: c imports the lazy ref from a, exercised via getter only.
import * as a from './a.mjs';
export const valueC = 'c';
export const peekA = () => a.valueA;
@@ -0,0 +1,8 @@
/**
* 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 = {cjsValue: 'from-cjs'};
@@ -0,0 +1,4 @@
{
"answer": 42,
"label": "json"
}
@@ -0,0 +1,12 @@
/**
* 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.
*/
// ESM module that pulls in a CJS dependency — exercises the
// CJS-as-ESM branch from inside the sync graph walker.
import {cjsValue} from './cjs-dep.cjs';
export {cjsValue};
@@ -0,0 +1,11 @@
/**
* 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 {EventEmitter} from 'events';
import * as nodePath from 'node:path';
export {EventEmitter, nodePath};
@@ -0,0 +1,11 @@
/**
* 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.
*/
// `export const base64Value = 'b64'` encoded as base64.
import {base64Value} from 'data:text/javascript;base64,ZXhwb3J0IGNvbnN0IGJhc2U2NFZhbHVlID0gJ2I2NCc7';
export {base64Value};
@@ -0,0 +1,10 @@
/**
* 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 {dataValue} from 'data:text/javascript,export const dataValue = 99';
export {dataValue};
@@ -0,0 +1,11 @@
/**
* 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 {fromA, valueB, valueC} from './a.mjs';
import {peekA} from './c.mjs';
export {fromA, peekA, valueB, valueC};
@@ -0,0 +1,12 @@
/**
* 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.
*/
// Exercises the `importModuleDynamically` callback path.
export async function loadA() {
const mod = await import('./a.mjs');
return mod.fromA;
}
@@ -0,0 +1,11 @@
/**
* 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.
*/
// Exercises the @jest/globals special-case in the sync resolver.
import {jest} from '@jest/globals';
export const hasJest = typeof jest === 'object' && jest !== null;
@@ -0,0 +1,10 @@
/**
* 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 data from './data.json' with {type: 'json'};
export {data};
@@ -0,0 +1,8 @@
/**
* 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.
*/
export const url = import.meta.url;
@@ -0,0 +1,8 @@
/**
* 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.
*/
export {greeting} from './mock-target.mjs';
@@ -0,0 +1,8 @@
/**
* 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.
*/
export {greeting} from './mock-target.mjs';
@@ -0,0 +1,11 @@
/**
* 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.
*/
// Importing a TLA dep makes the whole graph async — exercises the
// `hasAsyncGraph()` fallback in the v24.9+ sync core.
export {value} from './with-tla.mjs';
export const wrapper = 'wrapper';
@@ -0,0 +1,8 @@
/**
* 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.
*/
export {answer} from 'virtual-only-pkg';
@@ -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.
*/
// Minimal wasm module: just the magic bytes + version, no exports.
// Exercises the `application/wasm` data: URI branch and the wasm sync
// constructor in the v24.9+ sync core.
import * as wasmMod from 'data:application/wasm;base64,AGFzbQEAAAA=';
export {wasmMod};
@@ -0,0 +1,8 @@
/**
* 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.
*/
export const greeting = 'real';
@@ -0,0 +1,3 @@
{
"name": "test_esm_sync_graph_root"
}
@@ -0,0 +1,10 @@
/**
* 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.
*/
let count = 0;
export const increment = () => ++count;
export const getCount = () => count;
@@ -0,0 +1,8 @@
/**
* 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.
*/
export const broken =;
@@ -0,0 +1,10 @@
/**
* 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.
*/
// Top-level await forces the async-evaluate fallback even on the v24.9+ sync path.
const resolved = await Promise.resolve('tla-value');
export const value = resolved;
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -22366,11 +22366,11 @@ __metadata:
linkType: hard
"uuid@npm:^11.1.0":
version: 11.1.0
resolution: "uuid@npm:11.1.0"
version: 11.1.1
resolution: "uuid@npm:11.1.1"
bin:
uuid: dist/esm/bin/uuid
checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5
checksum: 10/16411d3dc12a08d6691616c09a75e66a7f900ba1beef6628a76fe0602f82fae2ee537b564d0b7bc95c24f58d059ca9b58c75a1e806118efb50e17822ff00ddd2
languageName: node
linkType: hard
@@ -23399,11 +23399,11 @@ __metadata:
linkType: hard
"yaml@npm:^2.6.1":
version: 2.8.1
resolution: "yaml@npm:2.8.1"
version: 2.8.3
resolution: "yaml@npm:2.8.3"
bin:
yaml: bin.mjs
checksum: 10/eae07b3947d405012672ec17ce27348aea7d1fa0534143355d24a43a58f5e05652157ea2182c4fe0604f0540be71f99f1173f9d61018379404507790dff17665
checksum: 10/ecad41d39d34fae5cc17ea2d4b7f7f55faacd45cbce8983ba22d48d1ed1a92ed242ea49ea813a79ac39a69f75f9c5a03e7b5395fd954d55476f25e21a47c141d
languageName: node
linkType: hard