diff --git a/CHANGELOG.md b/CHANGELOG.md index e061d9be64c7..57bbc7d8a17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `[docs]` Correct confusing filename in `enableAutomock` example ([#10055](https://github.com/facebook/jest/pull/10055)) - `[jest-core]` 🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉 ([#10000](https://github.com/facebook/jest/pull/10000)) - `[jest-core, jest-reporters, jest-test-result, jest-types]` Cleanup `displayName` type ([#10049](https://github.com/facebook/jest/pull/10049)) +- `[jest-runtime]` Jest-internal sandbox escape hatch ([#9907](https://github.com/facebook/jest/pull/9907)) ### Performance diff --git a/packages/jest-runtime/src/__tests__/runtime_require_resolve.test.ts b/packages/jest-runtime/src/__tests__/runtime_require_resolve.test.ts new file mode 100644 index 000000000000..d7ce25458f44 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_require_resolve.test.ts @@ -0,0 +1,84 @@ +/** + * 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 type {Config} from '@jest/types'; +import type Runtime from '..'; +import {createOutsideJestVmPath} from '../helpers'; + +let createRuntime: ( + path: string, + config?: Config.InitialOptions, +) => Promise; + +describe('Runtime require.resolve', () => { + beforeEach(() => { + createRuntime = require('createRuntime'); + }); + + it('resolves a module path', async () => { + const runtime = await createRuntime(__filename); + const resolved = runtime.requireModule( + runtime.__mockRootPath, + './resolve_self.js', + ); + expect(resolved).toEqual(require.resolve('./test_root/resolve_self.js')); + }); + + it('resolves a module path with moduleNameMapper', async () => { + const runtime = await createRuntime(__filename, { + moduleNameMapper: { + '^testMapped/(.*)': '/mapped_dir/$1', + }, + }); + const resolved = runtime.requireModule( + runtime.__mockRootPath, + './resolve_mapped.js', + ); + expect(resolved).toEqual( + require.resolve('./test_root/mapped_dir/moduleInMapped.js'), + ); + }); + + describe('with the OUTSIDE_JEST_VM_RESOLVE_OPTION', () => { + it('forwards to the real Node require in an internal context', async () => { + const runtime = await createRuntime(__filename); + const module = runtime.requireInternalModule( + runtime.__mockRootPath, + './resolve_and_require_outside.js', + ); + expect(module.required).toBe( + require('./test_root/create_require_module'), + ); + }); + + it('ignores the option in an external context', async () => { + const runtime = await createRuntime(__filename); + const module = runtime.requireModule( + runtime.__mockRootPath, + './resolve_and_require_outside.js', + ); + expect(module.required.foo).toBe('foo'); + expect(module.required).not.toBe( + require('./test_root/create_require_module'), + ); + }); + + // make sure we also check isInternal during require, not just during resolve + it('does not understand a self-constructed outsideJestVmPath in an external context', async () => { + const runtime = await createRuntime(__filename); + expect(() => + runtime.requireModule( + runtime.__mockRootPath, + createOutsideJestVmPath( + require.resolve('./test_root/create_require_module.js'), + ), + ), + ).toThrow(/cannot find.+create_require_module/i); + }); + }); +}); diff --git a/packages/jest-runtime/src/__tests__/test_root/resolve_and_require_outside.js b/packages/jest-runtime/src/__tests__/test_root/resolve_and_require_outside.js new file mode 100644 index 000000000000..e39ba2527380 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/test_root/resolve_and_require_outside.js @@ -0,0 +1,19 @@ +/** + * 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. + */ + +'use strict'; + +const resolved = require.resolve('./create_require_module', { + [Symbol.for('OUTSIDE_JEST_VM_RESOLVE_OPTION')]: true, +}); +if (typeof resolved !== 'string') { + throw new Error('require.resolve not spec-compliant: must return a string'); +} +module.exports = { + required: require(resolved), + resolved, +}; diff --git a/packages/jest-runtime/src/__tests__/test_root/resolve_mapped.js b/packages/jest-runtime/src/__tests__/test_root/resolve_mapped.js new file mode 100644 index 000000000000..377e18fcaa0f --- /dev/null +++ b/packages/jest-runtime/src/__tests__/test_root/resolve_mapped.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +'use strict'; + +module.exports = require.resolve('testMapped/moduleInMapped'); diff --git a/packages/jest-runtime/src/__tests__/test_root/resolve_self.js b/packages/jest-runtime/src/__tests__/test_root/resolve_self.js new file mode 100644 index 000000000000..63195879cf57 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/test_root/resolve_self.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +'use strict'; + +module.exports = require.resolve('./resolve_self'); diff --git a/packages/jest-runtime/src/helpers.ts b/packages/jest-runtime/src/helpers.ts index fb42e2b1e4ca..4a7d28aa7f27 100644 --- a/packages/jest-runtime/src/helpers.ts +++ b/packages/jest-runtime/src/helpers.ts @@ -10,6 +10,25 @@ import slash = require('slash'); import glob = require('glob'); import type {Config} from '@jest/types'; +const OUTSIDE_JEST_VM_PROTOCOL = 'jest-main:'; +// String manipulation is easier here, fileURLToPath is only in newer Nodes, +// plus setting non-standard protocols on URL objects is difficult. +export const createOutsideJestVmPath = (path: string): string => + OUTSIDE_JEST_VM_PROTOCOL + '//' + encodeURIComponent(path); +export const decodePossibleOutsideJestVmPath = ( + outsideJestVmPath: string, +): string | undefined => { + if (outsideJestVmPath.startsWith(OUTSIDE_JEST_VM_PROTOCOL)) { + return decodeURIComponent( + outsideJestVmPath.replace( + new RegExp('^' + OUTSIDE_JEST_VM_PROTOCOL + '//'), + '', + ), + ); + } + return undefined; +}; + export const findSiblingsWithFileExtension = ( moduleFileExtensions: Config.ProjectConfig['moduleFileExtensions'], from: Config.Path, diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 47d66dbae539..2cfe0a14d10a 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -45,7 +45,11 @@ import {CoverageInstrumenter, V8Coverage} from 'collect-v8-coverage'; import * as fs from 'graceful-fs'; import {run as cliRun} from './cli'; import {options as cliOptions} from './cli/args'; -import {findSiblingsWithFileExtension} from './helpers'; +import { + createOutsideJestVmPath, + decodePossibleOutsideJestVmPath, + findSiblingsWithFileExtension, +} from './helpers'; import type {Context as JestContext} from './types'; import jestMock = require('jest-mock'); import HasteMap = require('jest-haste-map'); @@ -81,7 +85,13 @@ const defaultTransformOptions: InternalModuleOptions = { type InitialModule = Partial & Pick; type ModuleRegistry = Map; -type ResolveOptions = Parameters[1]; + +const OUTSIDE_JEST_VM_RESOLVE_OPTION = Symbol.for( + 'OUTSIDE_JEST_VM_RESOLVE_OPTION', +); +type ResolveOptions = Parameters[1] & { + [OUTSIDE_JEST_VM_RESOLVE_OPTION]?: true; +}; type StringMap = Map; type BooleanMap = Map; @@ -547,6 +557,13 @@ class Runtime { } requireInternalModule(from: Config.Path, to?: string): T { + if (to) { + const outsideJestVmPath = decodePossibleOutsideJestVmPath(to); + if (outsideJestVmPath) { + return require(outsideJestVmPath); + } + } + return this.requireModule(from, to, { isInternalModule: true, supportsDynamicImport: false, @@ -1307,9 +1324,20 @@ class Runtime { from: InitialModule, options?: InternalModuleOptions, ): NodeRequire { - // TODO: somehow avoid having to type the arguments - they should come from `NodeRequire/LocalModuleRequire.resolve` - const resolve = (moduleName: string, options: ResolveOptions) => - this._requireResolve(from.filename, moduleName, options); + const resolve = (moduleName: string, resolveOptions?: ResolveOptions) => { + const resolved = this._requireResolve( + from.filename, + moduleName, + resolveOptions, + ); + if ( + resolveOptions?.[OUTSIDE_JEST_VM_RESOLVE_OPTION] && + options?.isInternalModule + ) { + return createOutsideJestVmPath(resolved); + } + return resolved; + }; resolve.paths = (moduleName: string) => this._requireResolvePaths(from.filename, moduleName);