diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f65a4894b7..38cb3beac846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - `[babel-preset-jest]` [**BREAKING**] Export a function instead of an object for Babel 7 compatibility ([#7203](https://github.com/facebook/jest/pull/7203)) - `[expect]` Check constructor equality in .toStrictEqual() ([#7005](https://github.com/facebook/jest/pull/7005)) - `[jest-util]` Add `jest.getTimerCount()` to get the count of scheduled fake timers ([#7285](https://github.com/facebook/jest/pull/7285)) +- `[jest-config]` Add `dependencyExtractor` option to use a custom module to extract dependencies from files ([#7313](https://github.com/facebook/jest/pull/7313)) ### Fixes diff --git a/docs/Configuration.md b/docs/Configuration.md index 35bddeccbdf6..8035bb5c8278 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -257,6 +257,14 @@ Jest will fail if: - The `./src/api/very-important-module.js` file has less than 100% coverage. - Every remaining file combined has less than 50% coverage (`global`). +### `dependencyExtractor` [string] + +Default: `undefined` + +This option allows the use of a custom dependency extractor. It must be a node module that exports a function expecting a string as the first argument for the code to analyze and Jest's dependency extractor as the second argument (in case you only want to extend it). + +The function should return an iterable (`Array`, `Set`, etc.) with the dependencies found in the code. + ### `errorOnDeprecated` [boolean] Default: `false` diff --git a/e2e/__tests__/__snapshots__/show_config.test.js.snap b/e2e/__tests__/__snapshots__/show_config.test.js.snap index f3982dde87f3..cf77d55b5751 100644 --- a/e2e/__tests__/__snapshots__/show_config.test.js.snap +++ b/e2e/__tests__/__snapshots__/show_config.test.js.snap @@ -13,6 +13,7 @@ exports[`--showConfig outputs config info and exits 1`] = ` \\"/node_modules/\\" ], \\"cwd\\": \\"<>\\", + \\"dependencyExtractor\\": null, \\"detectLeaks\\": false, \\"detectOpenHandles\\": false, \\"errorOnDeprecated\\": false, diff --git a/e2e/__tests__/find_related_files.test.js b/e2e/__tests__/find_related_files.test.js index 2024e1dd97e5..f7ba19cf16bf 100644 --- a/e2e/__tests__/find_related_files.test.js +++ b/e2e/__tests__/find_related_files.test.js @@ -41,6 +41,46 @@ describe('--findRelatedTests flag', () => { expect(stderr).toMatch(summaryMsg); }); + test('runs tests related to filename with a custom dependency extractor', () => { + writeFiles(DIR, { + '.watchmanconfig': '', + '__tests__/test.test.js': ` + const dynamicImport = path => Promise.resolve(require(path)); + test('a', () => dynamicImport('../a').then(a => { + expect(a.foo).toBe(5); + })); + `, + 'a.js': 'module.exports = {foo: 5};', + 'dependencyExtractor.js': ` + const DYNAMIC_IMPORT_RE = /(?:^|[^.]\\s*)(\\bdynamicImport\\s*?\\(\\s*?)([\`'"])([^\`'"]+)(\\2\\s*?\\))/g; + module.exports = function dependencyExtractor(code) { + const dependencies = new Set(); + const addDependency = (match, pre, quot, dep, post) => { + dependencies.add(dep); + return match; + }; + code.replace(DYNAMIC_IMPORT_RE, addDependency); + return dependencies; + }; + `, + 'package.json': JSON.stringify({ + jest: { + dependencyExtractor: '/dependencyExtractor.js', + testEnvironment: 'node', + }, + }), + }); + + const {stdout} = runJest(DIR, ['a.js']); + expect(stdout).toMatch(''); + + const {stderr} = runJest(DIR, ['--findRelatedTests', 'a.js']); + expect(stderr).toMatch('PASS __tests__/test.test.js'); + + const summaryMsg = 'Ran all test suites related to files matching /a.js/i.'; + expect(stderr).toMatch(summaryMsg); + }); + test('generates coverage report for filename', () => { writeFiles(DIR, { '.watchmanconfig': '', diff --git a/jest.config.js b/jest.config.js index 06cb6ca2e84c..9c16175c878b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -40,6 +40,7 @@ module.exports = { '/packages/jest-cli/src/__tests__/__fixtures__/', '/packages/jest-cli/src/lib/__tests__/fixtures/', '/packages/jest-haste-map/src/__tests__/haste_impl.js', + '/packages/jest-haste-map/src/__tests__/dependencyExtractor.js', '/packages/jest-resolve-dependencies/src/__tests__/__fixtures__/', '/packages/jest-runtime/src/__tests__/defaultResolver.js', '/packages/jest-runtime/src/__tests__/module_dir/', diff --git a/packages/jest-cli/src/lib/__tests__/__snapshots__/init.test.js.snap b/packages/jest-cli/src/lib/__tests__/__snapshots__/init.test.js.snap index 8f49360f6ebc..d0b63fa1c18a 100644 --- a/packages/jest-cli/src/lib/__tests__/__snapshots__/init.test.js.snap +++ b/packages/jest-cli/src/lib/__tests__/__snapshots__/init.test.js.snap @@ -63,6 +63,9 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: null, + // A path to a custom dependency extractor + // dependencyExtractor: null, + // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, diff --git a/packages/jest-config/src/Defaults.js b/packages/jest-config/src/Defaults.js index b6aafa6e13ad..ea40b3cf4e05 100644 --- a/packages/jest-config/src/Defaults.js +++ b/packages/jest-config/src/Defaults.js @@ -30,6 +30,7 @@ export default ({ coverageReporters: ['json', 'text', 'lcov', 'clover'], coverageThreshold: null, cwd: process.cwd(), + dependencyExtractor: null, detectLeaks: false, detectOpenHandles: false, errorOnDeprecated: false, diff --git a/packages/jest-config/src/Descriptions.js b/packages/jest-config/src/Descriptions.js index 48c3b2d321ad..6ff881d1501d 100644 --- a/packages/jest-config/src/Descriptions.js +++ b/packages/jest-config/src/Descriptions.js @@ -26,6 +26,7 @@ export default ({ 'A list of reporter names that Jest uses when writing coverage reports', coverageThreshold: 'An object that configures minimum threshold enforcement for coverage results', + dependencyExtractor: 'A path to a custom dependency extractor', errorOnDeprecated: 'Make calling deprecated APIs throw helpful error messages', forceCoverageMatch: diff --git a/packages/jest-config/src/ValidConfig.js b/packages/jest-config/src/ValidConfig.js index cb732dc76555..2d2d4128bc48 100644 --- a/packages/jest-config/src/ValidConfig.js +++ b/packages/jest-config/src/ValidConfig.js @@ -40,6 +40,7 @@ export default ({ statements: 100, }, }, + dependencyExtractor: '/dependencyExtractor.js', displayName: 'project-name', errorOnDeprecated: false, expand: false, diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index ec6a856c75f0..b1832b94af6a 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -160,6 +160,7 @@ const groupOptions = ( clearMocks: options.clearMocks, coveragePathIgnorePatterns: options.coveragePathIgnorePatterns, cwd: options.cwd, + dependencyExtractor: options.dependencyExtractor, detectLeaks: options.detectLeaks, detectOpenHandles: options.detectOpenHandles, displayName: options.displayName, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index fc04dd34deec..ef6886ce2a3b 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -483,6 +483,7 @@ export default function normalize(options: InitialOptions, argv: Argv) { replaceRootDirInPath(options.rootDir, options[key]), ); break; + case 'dependencyExtractor': case 'globalSetup': case 'globalTeardown': case 'moduleLoader': diff --git a/packages/jest-haste-map/src/__tests__/dependencyExtractor.js b/packages/jest-haste-map/src/__tests__/dependencyExtractor.js new file mode 100644 index 000000000000..3e872ae3227e --- /dev/null +++ b/packages/jest-haste-map/src/__tests__/dependencyExtractor.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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 blockCommentRe = /\/\*[^]*?\*\//g; +const lineCommentRe = /\/\/.*/g; +const LOAD_MODULE_RE = /(?:^|[^.]\s*)(\bloadModule\s*?\(\s*?)([`'"])([^`'"]+)(\2\s*?\))/g; + +module.exports = function dependencyExtractor( + code, + defaultDependencyExtractor, +) { + const dependencies = defaultDependencyExtractor(code); + + const addDependency = (match, pre, quot, dep, post) => { + dependencies.add(dep); + return match; + }; + + code + .replace(blockCommentRe, '') + .replace(lineCommentRe, '') + .replace(LOAD_MODULE_RE, addDependency); + + return dependencies; +}; diff --git a/packages/jest-haste-map/src/__tests__/index.test.js b/packages/jest-haste-map/src/__tests__/index.test.js index df716c9f0c09..3b1a49f37626 100644 --- a/packages/jest-haste-map/src/__tests__/index.test.js +++ b/packages/jest-haste-map/src/__tests__/index.test.js @@ -885,8 +885,11 @@ describe('HasteMap', () => { it('distributes work across workers', () => { const jestWorker = require('jest-worker'); + const path = require('path'); + const dependencyExtractor = path.join(__dirname, 'dependencyExtractor.js'); return new HasteMap( Object.assign({}, defaultConfig, { + dependencyExtractor, hasteImplModulePath: undefined, maxWorkers: 4, }), @@ -902,6 +905,7 @@ describe('HasteMap', () => { { computeDependencies: true, computeSha1: false, + dependencyExtractor, filePath: '/project/fruits/Banana.js', hasteImplModulePath: undefined, rootDir: '/project', @@ -911,6 +915,7 @@ describe('HasteMap', () => { { computeDependencies: true, computeSha1: false, + dependencyExtractor, filePath: '/project/fruits/Pear.js', hasteImplModulePath: undefined, rootDir: '/project', @@ -920,6 +925,7 @@ describe('HasteMap', () => { { computeDependencies: true, computeSha1: false, + dependencyExtractor, filePath: '/project/fruits/Strawberry.js', hasteImplModulePath: undefined, rootDir: '/project', @@ -929,6 +935,7 @@ describe('HasteMap', () => { { computeDependencies: true, computeSha1: false, + dependencyExtractor, filePath: '/project/fruits/__mocks__/Pear.js', hasteImplModulePath: undefined, rootDir: '/project', @@ -938,6 +945,7 @@ describe('HasteMap', () => { { computeDependencies: true, computeSha1: false, + dependencyExtractor, filePath: '/project/vegetables/Melon.js', hasteImplModulePath: undefined, rootDir: '/project', diff --git a/packages/jest-haste-map/src/__tests__/worker.test.js b/packages/jest-haste-map/src/__tests__/worker.test.js index 426502dd3d92..3c044a3668c3 100644 --- a/packages/jest-haste-map/src/__tests__/worker.test.js +++ b/packages/jest-haste-map/src/__tests__/worker.test.js @@ -32,6 +32,7 @@ describe('worker', () => { '/project/fruits/Pear.js': ` const Banana = require("Banana"); const Strawberry = require('Strawberry'); + const Lime = loadModule('Lime'); `, '/project/fruits/Strawberry.js': ` // Strawberry! @@ -95,6 +96,19 @@ describe('worker', () => { }); }); + it('accepts a custom dependency extractor', async () => { + expect( + await worker({ + computeDependencies: true, + dependencyExtractor: path.join(__dirname, 'dependencyExtractor.js'), + filePath: '/project/fruits/Pear.js', + rootDir, + }), + ).toEqual({ + dependencies: ['Banana', 'Strawberry', 'Lime'], + }); + }); + it('delegates to hasteImplModulePath for getting the id', async () => { expect( await worker({ @@ -179,7 +193,7 @@ describe('worker', () => { filePath: '/project/fruits/Pear.js', rootDir, }), - ).toEqual({sha1: '0cb0930919e068f146da84d9a0ad0182e4bdb673'}); + ).toEqual({sha1: 'c7a7a68a1c8aaf452669dd2ca52ac4a434d25552'}); await expect( getSha1({computeSha1: true, filePath: '/i/dont/exist.js', rootDir}), diff --git a/packages/jest-haste-map/src/index.js b/packages/jest-haste-map/src/index.js index f7acf0cbfee4..fabadb3b33ee 100644 --- a/packages/jest-haste-map/src/index.js +++ b/packages/jest-haste-map/src/index.js @@ -52,6 +52,7 @@ type Options = { computeDependencies?: boolean, computeSha1?: boolean, console?: Console, + dependencyExtractor?: string, extensions: Array, forceNodeFilesystemAPI?: boolean, hasteImplModulePath?: string, @@ -75,6 +76,7 @@ type InternalOptions = { cacheDirectory: string, computeDependencies: boolean, computeSha1: boolean, + dependencyExtractor?: string, extensions: Array, forceNodeFilesystemAPI: boolean, hasteImplModulePath?: string, @@ -233,6 +235,7 @@ class HasteMap extends EventEmitter { ? true : options.computeDependencies, computeSha1: options.computeSha1 || false, + dependencyExtractor: options.dependencyExtractor, extensions: options.extensions, forceNodeFilesystemAPI: !!options.forceNodeFilesystemAPI, hasteImplModulePath: options.hasteImplModulePath, @@ -504,6 +507,7 @@ class HasteMap extends EventEmitter { .getSha1({ computeDependencies: this._options.computeDependencies, computeSha1, + dependencyExtractor: this._options.dependencyExtractor, filePath, hasteImplModulePath: this._options.hasteImplModulePath, rootDir, @@ -567,6 +571,7 @@ class HasteMap extends EventEmitter { .worker({ computeDependencies: this._options.computeDependencies, computeSha1, + dependencyExtractor: this._options.dependencyExtractor, filePath, hasteImplModulePath: this._options.hasteImplModulePath, rootDir, diff --git a/packages/jest-haste-map/src/lib/__tests__/extractRequires.test.js b/packages/jest-haste-map/src/lib/__tests__/extractRequires.test.js index a6172e0dcb8e..a349d083735e 100644 --- a/packages/jest-haste-map/src/lib/__tests__/extractRequires.test.js +++ b/packages/jest-haste-map/src/lib/__tests__/extractRequires.test.js @@ -18,7 +18,9 @@ it('extracts both requires and imports from code', () => { import('module3').then(module3 => {})'; `; - expect(extractRequires(code)).toEqual(['module1', 'module2', 'module3']); + expect(extractRequires(code)).toEqual( + new Set(['module1', 'module2', 'module3']), + ); }); it('extracts requires in order', () => { @@ -28,13 +30,15 @@ it('extracts requires in order', () => { const module3 = require('module3'); `; - expect(extractRequires(code)).toEqual(['module1', 'module2', 'module3']); + expect(extractRequires(code)).toEqual( + new Set(['module1', 'module2', 'module3']), + ); }); it('strips out comments from code', () => { const code = `// comment const module2 = require('module2');`; - expect(extractRequires(code)).toEqual([]); + expect(extractRequires(code)).toEqual(new Set([])); }); it('ignores requires in comments', () => { @@ -45,7 +49,7 @@ it('ignores requires in comments', () => { ' */', ].join('\n'); - expect(extractRequires(code)).toEqual([]); + expect(extractRequires(code)).toEqual(new Set([])); }); it('ignores requires in comments with Windows line endings', () => { @@ -56,7 +60,7 @@ it('ignores requires in comments with Windows line endings', () => { ' */', ].join('\r\n'); - expect(extractRequires(code)).toEqual([]); + expect(extractRequires(code)).toEqual(new Set([])); }); it('ignores requires in comments with unicode line endings', () => { @@ -68,7 +72,7 @@ it('ignores requires in comments with unicode line endings', () => { ' */', ].join(''); - expect(extractRequires(code)).toEqual([]); + expect(extractRequires(code)).toEqual(new Set([])); }); it('does not contain duplicates', () => { @@ -77,7 +81,7 @@ it('does not contain duplicates', () => { const module1Dup = require('module1'); `; - expect(extractRequires(code)).toEqual(['module1']); + expect(extractRequires(code)).toEqual(new Set(['module1'])); }); it('ignores type imports', () => { @@ -89,7 +93,7 @@ it('ignores type imports', () => { "} from 'wham'", ].join('\r\n'); - expect(extractRequires(code)).toEqual([]); + expect(extractRequires(code)).toEqual(new Set([])); }); it('ignores type exports', () => { @@ -99,25 +103,25 @@ it('ignores type exports', () => { "export * from 'module1'", ].join('\r\n'); - expect(extractRequires(code)).toEqual(['module1']); + expect(extractRequires(code)).toEqual(new Set(['module1'])); }); it('understands require.requireActual', () => { const code = `require.requireActual('pizza');`; - expect(extractRequires(code)).toEqual(['pizza']); + expect(extractRequires(code)).toEqual(new Set(['pizza'])); }); it('understands jest.requireActual', () => { const code = `jest.requireActual('whiskey');`; - expect(extractRequires(code)).toEqual(['whiskey']); + expect(extractRequires(code)).toEqual(new Set(['whiskey'])); }); it('understands require.requireMock', () => { const code = `require.requireMock('cheeseburger');`; - expect(extractRequires(code)).toEqual(['cheeseburger']); + expect(extractRequires(code)).toEqual(new Set(['cheeseburger'])); }); it('understands jest.requireMock', () => { const code = `jest.requireMock('scotch');`; - expect(extractRequires(code)).toEqual(['scotch']); + expect(extractRequires(code)).toEqual(new Set(['scotch'])); }); diff --git a/packages/jest-haste-map/src/lib/extractRequires.js b/packages/jest-haste-map/src/lib/extractRequires.js index 7457f44b6236..dfdcece33a29 100644 --- a/packages/jest-haste-map/src/lib/extractRequires.js +++ b/packages/jest-haste-map/src/lib/extractRequires.js @@ -18,7 +18,7 @@ const replacePatterns = { REQUIRE_RE: /(?:^|[^.]\s*)(\brequire\s*?\(\s*?)([`'"])([^`'"]+)(\2\s*?\))/g, }; -export default function extractRequires(code: string): Array { +export default function extractRequires(code: string): Set { const dependencies = new Set(); const addDependency = (match, pre, quot, dep, post) => { dependencies.add(dep); @@ -34,5 +34,5 @@ export default function extractRequires(code: string): Array { .replace(replacePatterns.REQUIRE_RE, addDependency) .replace(replacePatterns.DYNAMIC_IMPORT_RE, addDependency); - return Array.from(dependencies); + return dependencies; } diff --git a/packages/jest-haste-map/src/types.js b/packages/jest-haste-map/src/types.js index caba081b9a9a..8ee12a279fb5 100644 --- a/packages/jest-haste-map/src/types.js +++ b/packages/jest-haste-map/src/types.js @@ -15,6 +15,7 @@ export type Mapper = (item: string) => ?Array; export type WorkerMessage = { computeDependencies: boolean, computeSha1: boolean, + dependencyExtractor?: string, rootDir: string, filePath: string, hasteImplModulePath?: string, diff --git a/packages/jest-haste-map/src/worker.js b/packages/jest-haste-map/src/worker.js index acf575098e8f..6096372a6aa2 100644 --- a/packages/jest-haste-map/src/worker.js +++ b/packages/jest-haste-map/src/worker.js @@ -77,7 +77,13 @@ export async function worker(data: WorkerMessage): Promise { } if (computeDependencies) { - dependencies = extractRequires(getContent()); + const content = getContent(); + dependencies = Array.from( + data.dependencyExtractor + ? // $FlowFixMe + require(data.dependencyExtractor)(content, extractRequires) + : extractRequires(content), + ); } if (id) { diff --git a/packages/jest-runtime/src/index.js b/packages/jest-runtime/src/index.js index a24ed1712fd3..f60b33b0dae6 100644 --- a/packages/jest-runtime/src/index.js +++ b/packages/jest-runtime/src/index.js @@ -237,6 +237,7 @@ class Runtime { return new HasteMap({ cacheDirectory: config.cacheDirectory, console: options && options.console, + dependencyExtractor: config.dependencyExtractor, extensions: [Snapshot.EXTENSION].concat(config.moduleFileExtensions), hasteImplModulePath: config.haste.hasteImplModulePath, ignorePattern, diff --git a/types/Config.js b/types/Config.js index c152976c0957..957cb3afeaab 100644 --- a/types/Config.js +++ b/types/Config.js @@ -36,6 +36,7 @@ export type DefaultOptions = {| coverageReporters: Array, coverageThreshold: ?{global: {[key: string]: number}}, cwd: Path, + dependencyExtractor: ?string, errorOnDeprecated: boolean, expand: boolean, filter: ?Path, @@ -104,6 +105,7 @@ export type InitialOptions = { coveragePathIgnorePatterns?: Array, coverageReporters?: Array, coverageThreshold?: {global: {[key: string]: number}}, + dependencyExtractor?: string, detectLeaks?: boolean, detectOpenHandles?: boolean, displayName?: string, @@ -249,6 +251,7 @@ export type ProjectConfig = {| clearMocks: boolean, coveragePathIgnorePatterns: Array, cwd: Path, + dependencyExtractor?: string, detectLeaks: boolean, detectOpenHandles: boolean, displayName: ?string,