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 @@ -8,6 +8,7 @@

### Fixes

- `[jest-runtime]`: Support CJS-in-ESM exports via `"module.exports"` named exports ([#16277](https://github.com/jestjs/jest/pull/16277))
- `[expect, jest-message-util, jest-pattern, jest-regex-util, jest-util]` Revert `node:` protocol imports to restore webpack/browser-bundle compatibility ([#16167](https://github.com/jestjs/jest/pull/16167))
- `[expect]` Widen `toMatchObject` and `objectContaining` parameter type from `Record<string, unknown>` to `object` so class instances are accepted ([#16196](https://github.com/jestjs/jest/pull/16196))
- `[jest-mock]` `mockResolvedValue` / `mockRejectedValue` now see all overload return types, so a Promise-returning overload survives even when a later overload returns a non-Promise (e.g. `pg.Client['end']`) ([#16237](https://github.com/jestjs/jest/pull/16237))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ describe('Runtime loadCjsAsEsm', () => {
);
});

describe('Runtime loadCjsAsEsm module.exports unwrapping', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
});

testWithSyncEsm(
'require()s a ESM module exporting "module.exports"',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const first = runtime.requireModule(
FROM,
'./module-exports-export-name.mjs',
);
expect(first).toEqual({fromModuleExports: true});
expect(first.named).toBeUndefined();

// Second require hits CjsLoader's ESM-registry cache fast path, which
// must apply the same unwrapping as the cold path above.
const second = runtime.requireModule(
FROM,
'./module-exports-export-name.mjs',
);
expect(second).toBe(first);
},
);
});

describe('Runtime loadCjsAsEsm SyntaxError fallback', () => {
beforeEach(() => {
createRuntime = require('createRuntime');
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
const unwrapped = {fromModuleExports: true};

export {unwrapped as 'module.exports'};
export const named = 'should-be-invisible-once-unwrapped';
8 changes: 6 additions & 2 deletions packages/jest-runtime/src/internals/CjsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*/

import * as path from 'node:path';
import type {Module as VMModule} from 'node:vm';
import type {JestEnvironment, Module} from '@jest/environment';
import {isError} from 'jest-util';
import type {MockState} from './MockState';
Expand Down Expand Up @@ -106,7 +105,12 @@ export class CjsLoader {
const reg = this.registries.getActiveEsmRegistry();
const cached = reg.get(modulePath);
if (cached && !(cached instanceof Promise)) {
return (cached as VMModule).namespace as T;
const cachedNamespace = cached.namespace as Record<string, unknown>;
return (
'module.exports' in cachedNamespace
? cachedNamespace['module.exports']
: cachedNamespace
) as T;
}
return this.requireEsm<T>(modulePath);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/jest-runtime/src/internals/EsmLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,10 @@ export class EsmLoader {
error.code = 'ERR_REQUIRE_ESM';
throw error;
}
return module.namespace as T;
const namespace = module.namespace as Record<string, unknown>;
return (
'module.exports' in namespace ? namespace['module.exports'] : namespace
) as T;
}

// Public for unit-test access. Production callers reach the sync graph
Expand Down
48 changes: 48 additions & 0 deletions packages/jest-runtime/src/internals/__tests__/CjsLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,54 @@ describe('CjsLoader.requireModule', () => {
expect(loader.requireModule('/from.js', './m.mjs')).toBe('esm-result');
expect(stubs.requireEsm).toHaveBeenCalledWith('/m.mjs');
});

testWithSyncEsm(
'unwraps a "module.exports" named export on the ESM cache-hit fast path',
() => {
const unwrapped = {fromModuleExports: true};
const esmRegistry = new Map<string, unknown>([
['/m.mjs', {namespace: {'module.exports': unwrapped, named: 'x'}}],
]);
const {loader, stubs} = makeLoader({
registries: {
getActiveCjsRegistry: jest.fn(() => new Map()),
getActiveEsmRegistry: jest.fn(() => esmRegistry),
} as unknown as jest.Mocked<ModuleRegistries>,
resolution: {
getCjsMockModule: jest.fn(() => null),
getModule: jest.fn(() => null),
isCoreModule: jest.fn(() => false),
resolveCjs: jest.fn(() => '/m.mjs'),
shouldLoadAsEsm: jest.fn(() => true),
} as unknown as jest.Mocked<Resolution>,
});
expect(loader.requireModule('/from.js', './m.mjs')).toBe(unwrapped);
expect(stubs.requireEsm).not.toHaveBeenCalled();
},
);

testWithSyncEsm(
'returns the raw namespace on the ESM cache-hit fast path when there is no "module.exports" export',
() => {
const namespace = {named: 'x'};
const esmRegistry = new Map<string, unknown>([['/m.mjs', {namespace}]]);
const {loader, stubs} = makeLoader({
registries: {
getActiveCjsRegistry: jest.fn(() => new Map()),
getActiveEsmRegistry: jest.fn(() => esmRegistry),
} as unknown as jest.Mocked<ModuleRegistries>,
resolution: {
getCjsMockModule: jest.fn(() => null),
getModule: jest.fn(() => null),
isCoreModule: jest.fn(() => false),
resolveCjs: jest.fn(() => '/m.mjs'),
shouldLoadAsEsm: jest.fn(() => true),
} as unknown as jest.Mocked<Resolution>,
});
expect(loader.requireModule('/from.js', './m.mjs')).toBe(namespace);
expect(stubs.requireEsm).not.toHaveBeenCalled();
},
);
});

describe('CjsLoader.loadModule', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/jest-runtime/src/internals/__tests__/EsmLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,29 @@ describe('EsmLoader.requireEsmModule', () => {
}),
);
});

testWithVmEsm(
'unwraps a "module.exports" named export instead of returning the raw namespace',
async () => {
const {context, esmRegistry, loader} = makeLoader();
const unwrapped = {fromModuleExports: true};
const synth = new SyntheticModule(
['module.exports', 'named'],
function () {
this.setExport('module.exports', unwrapped);
this.setExport('named', 'should-be-invisible');
},
{context, identifier: '/m.mjs'},
);
await synth.link(() => {
throw new Error('no deps');
});
await synth.evaluate();
esmRegistry.set('/m.mjs', synth);
const result = loader.requireEsmModule<any>('/m.mjs');
expect(result).toBe(unwrapped);
},
);
});

describe('EsmLoader.dynamicImportFromCjs (legacy linkAndEvaluate)', () => {
Expand Down
Loading