Skip to content

Commit

Permalink
Jest-internal sandbox escape hatch (#9907)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeysal committed May 25, 2020
1 parent 81712ba commit eff3eaa
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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

Expand Down
@@ -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<Runtime & {__mockRootPath: string}>;

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/(.*)': '<rootDir>/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<any>(
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<any>(
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<any>(
runtime.__mockRootPath,
createOutsideJestVmPath(
require.resolve('./test_root/create_require_module.js'),
),
),
).toThrow(/cannot find.+create_require_module/i);
});
});
});
@@ -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,
};
10 changes: 10 additions & 0 deletions 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');
10 changes: 10 additions & 0 deletions 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');
19 changes: 19 additions & 0 deletions packages/jest-runtime/src/helpers.ts
Expand Up @@ -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,
Expand Down
38 changes: 33 additions & 5 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -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');
Expand Down Expand Up @@ -81,7 +85,13 @@ const defaultTransformOptions: InternalModuleOptions = {
type InitialModule = Partial<Module> &
Pick<Module, 'children' | 'exports' | 'filename' | 'id' | 'loaded'>;
type ModuleRegistry = Map<string, InitialModule | Module>;
type ResolveOptions = Parameters<typeof require.resolve>[1];

const OUTSIDE_JEST_VM_RESOLVE_OPTION = Symbol.for(
'OUTSIDE_JEST_VM_RESOLVE_OPTION',
);
type ResolveOptions = Parameters<typeof require.resolve>[1] & {
[OUTSIDE_JEST_VM_RESOLVE_OPTION]?: true;
};

type StringMap = Map<string, string>;
type BooleanMap = Map<string, boolean>;
Expand Down Expand Up @@ -547,6 +557,13 @@ class Runtime {
}

requireInternalModule<T = unknown>(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,
Expand Down Expand Up @@ -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);

Expand Down

0 comments on commit eff3eaa

Please sign in to comment.