diff --git a/docs/en/CLI.md b/docs/en/CLI.md index 3dcc8482cbc7..f2e82648b32e 100644 --- a/docs/en/CLI.md +++ b/docs/en/CLI.md @@ -122,6 +122,10 @@ Alias: `-e`. Use this flag to show full diffs and errors instead of a patch. Find and run the tests that cover a space separated list of source files that were passed in as arguments. Useful for pre-commit hook integration to run the minimal amount of tests necessary. +### `--followSymlinks` + +Whether to follow symbolic linked files for file crawling. Linux/MacOSX only. Defaults to false. + ### `--forceExit` Force Jest to exit after all tests have completed running. This is useful when resources set up by test code cannot be adequately cleaned up. *Note: This feature is an escape-hatch. If Jest doesn't exit at the end of a test run, it means external resources are still being held on to or timers are still pending in your code. It is advised to tear down external resources after each test to make sure Jest can shut down cleanly.* diff --git a/integration_tests/__tests__/__snapshots__/show_config.test.js.snap b/integration_tests/__tests__/__snapshots__/show_config.test.js.snap index c4f023851bac..fd84de1b93e0 100644 --- a/integration_tests/__tests__/__snapshots__/show_config.test.js.snap +++ b/integration_tests/__tests__/__snapshots__/show_config.test.js.snap @@ -66,6 +66,7 @@ exports[`--showConfig outputs config info and exits 1`] = ` \\"clover\\" ], \\"expand\\": false, + \\"followSymlinks\\": false, \\"listTests\\": false, \\"mapCoverage\\": false, \\"maxWorkers\\": \\"[maxWorkers]\\", diff --git a/integration_tests/__tests__/follow-symlinks.test.js b/integration_tests/__tests__/follow-symlinks.test.js new file mode 100644 index 000000000000..2d56b91ad70d --- /dev/null +++ b/integration_tests/__tests__/follow-symlinks.test.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ +'use strict'; + +const runJest = require('../runJest'); +const {extractSummary} = require('../utils'); + +test("Don't follow symlinks by default", () => { + const {stdout} = runJest('follow-symlinks', []); + expect(stdout).toMatch('No tests found'); +}); + +test('Follow symlinks when followSymlinks flag is used', () => { + const {stderr, stdout} = runJest('follow-symlinks', ['--followSymlinks']); + expect(stdout).not.toMatch('No tests found'); + const {rest, summary} = extractSummary(stderr); + expect(rest).not.toMatch('Test suite failed to run'); + expect(summary).toMatch('1 passed, 1 total'); +}); diff --git a/integration_tests/follow-symlinks/__tests__/symlinked-to-actual.test.js b/integration_tests/follow-symlinks/__tests__/symlinked-to-actual.test.js new file mode 120000 index 000000000000..dc22048511a8 --- /dev/null +++ b/integration_tests/follow-symlinks/__tests__/symlinked-to-actual.test.js @@ -0,0 +1 @@ +../actual-test.js \ No newline at end of file diff --git a/integration_tests/follow-symlinks/actual-test.js b/integration_tests/follow-symlinks/actual-test.js new file mode 100644 index 000000000000..433821114db1 --- /dev/null +++ b/integration_tests/follow-symlinks/actual-test.js @@ -0,0 +1,3 @@ +test('actual-test', () => { + expect(true).toBeTruthy(); +}); diff --git a/integration_tests/follow-symlinks/package.json b/integration_tests/follow-symlinks/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/integration_tests/follow-symlinks/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/jest-cli/src/cli/args.js b/packages/jest-cli/src/cli/args.js index 2bf27d2a39a9..73aabb63e2ae 100644 --- a/packages/jest-cli/src/cli/args.js +++ b/packages/jest-cli/src/cli/args.js @@ -216,6 +216,11 @@ const options = { 'the minimal amount of tests necessary.', type: 'boolean', }, + followSymlinks: { + default: undefined, + description: 'Whether to include symbolic links in file crawling.', + type: 'boolean', + }, forceExit: { default: undefined, description: diff --git a/packages/jest-cli/src/cli/index.js b/packages/jest-cli/src/cli/index.js index 3456d839eb5c..7bb9d2b94505 100644 --- a/packages/jest-cli/src/cli/index.js +++ b/packages/jest-cli/src/cli/index.js @@ -269,6 +269,7 @@ const _buildContextsAndHasteMaps = async ( createDirectory(config.cacheDirectory); const hasteMapInstance = Runtime.createHasteMap(config, { console: new Console(outputStream, outputStream), + followSymlinks: globalConfig.followSymlinks, maxWorkers: globalConfig.maxWorkers, resetCache: !config.cache, watch: globalConfig.watch, diff --git a/packages/jest-config/src/defaults.js b/packages/jest-config/src/defaults.js index 5053a35f4bb2..c44561c53efd 100644 --- a/packages/jest-config/src/defaults.js +++ b/packages/jest-config/src/defaults.js @@ -38,6 +38,7 @@ module.exports = ({ coveragePathIgnorePatterns: [NODE_MODULES_REGEXP], coverageReporters: ['json', 'text', 'lcov', 'clover'], expand: false, + followSymlinks: false, globals: {}, haste: { providesModuleNodeModules: [], diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index c92558f6fba5..bfe0e79711c9 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -84,6 +84,7 @@ const getConfigs = ( coverageThreshold: options.coverageThreshold, expand: options.expand, findRelatedTests: options.findRelatedTests, + followSymlinks: options.followSymlinks, forceExit: options.forceExit, json: options.json, lastCommit: options.lastCommit, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index 1f6e24290a7e..4824c3214120 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -457,6 +457,7 @@ function normalize(options: InitialOptions, argv: Argv) { case 'expand': case 'globals': case 'findRelatedTests': + case 'followSymlinks': case 'forceExit': case 'listTests': case 'logHeapUsage': diff --git a/packages/jest-config/src/valid_config.js b/packages/jest-config/src/valid_config.js index 66139c1ad5ea..c3428afcc823 100644 --- a/packages/jest-config/src/valid_config.js +++ b/packages/jest-config/src/valid_config.js @@ -38,6 +38,7 @@ module.exports = ({ }, displayName: 'project-name', expand: false, + followSymlinks: false, forceExit: false, globals: {}, haste: { diff --git a/packages/jest-haste-map/src/crawlers/__tests__/node.test.js b/packages/jest-haste-map/src/crawlers/__tests__/node.test.js index 38f77520bbcc..3f7df7618f46 100644 --- a/packages/jest-haste-map/src/crawlers/__tests__/node.test.js +++ b/packages/jest-haste-map/src/crawlers/__tests__/node.test.js @@ -34,35 +34,44 @@ jest.mock('child_process', () => ({ jest.mock('fs', () => { let mtime = 32; - const stat = (path, callback) => { - setTimeout( - () => - callback(null, { - isDirectory() { - return path.endsWith('/directory'); - }, - isSymbolicLink() { - return false; - }, - mtime: { - getTime() { - return mtime++; + const fakeStat = ({isSymbolicLink}) => { + return jest.fn((path, callback) => { + setTimeout( + () => + callback(null, { + isDirectory() { + return path.endsWith('/directory'); }, - }, - }), - 0, - ); + isSymbolicLink() { + return isSymbolicLink(path); + }, + mtime: { + getTime() { + return mtime++; + }, + }, + }), + 0, + ); + }); }; return { - lstat: jest.fn(stat), + lstat: fakeStat({ + isSymbolicLink: path => path.indexOf('symlink') !== -1, + }), readdir: jest.fn((dir, callback) => { if (dir === '/fruits') { setTimeout(() => callback(null, ['directory', 'tomato.js']), 0); } else if (dir === '/fruits/directory') { - setTimeout(() => callback(null, ['strawberry.js']), 0); + setTimeout( + () => callback(null, ['kiwi-symlink.js', 'strawberry.js']), + 0, + ); } }), - stat: jest.fn(stat), + stat: fakeStat({ + isSymbolicLink: path => false, + }), }; }); @@ -132,6 +141,59 @@ describe('node crawler', () => { return promise; }); + it('crawls symlinks if followSymlinks flag is set to true', () => { + process.platform = 'linux'; + + childProcess = require('child_process'); + nodeCrawl = require('../node'); + + mockResponse = [ + '/fruits/pear.js', + '/fruits/strawberry.js', + '/fruits/tomato.js', + '/vegetables/melon.json', + ].join('\n'); + + const promise = nodeCrawl({ + data: { + files: Object.create(null), + }, + extensions: ['js', 'json'], + followSymlinks: true, + ignore: pearMatcher, + roots: ['/fruits', '/vegtables'], + }).then(data => { + expect(childProcess.spawn).lastCalledWith('find', [ + '/fruits', + '/vegtables', + '(', + '-type', + 'f', // regular files + '-o', + '-type', + 'l', // symbolic links + ')', + '(', + '-iname', + '*.js', + '-o', + '-iname', + '*.json', + ')', + ]); + + expect(data.files).not.toBe(null); + + expect(data.files).toEqual({ + '/fruits/strawberry.js': ['', 32, 0, []], + '/fruits/tomato.js': ['', 33, 0, []], + '/vegetables/melon.json': ['', 34, 0, []], + }); + }); + + return promise; + }); + it('updates only changed files', () => { process.platform = 'linux'; @@ -199,6 +261,26 @@ describe('node crawler', () => { }); }); + it('crawls symlinks if followSymlinks flag is set to true and using node fs APIs', () => { + nodeCrawl = require('../node'); + + const files = Object.create(null); + return nodeCrawl({ + data: {files}, + extensions: ['js'], + followSymlinks: true, + forceNodeFilesystemAPI: true, + ignore: pearMatcher, + roots: ['/fruits'], + }).then(data => { + expect(data.files).toEqual({ + '/fruits/directory/kiwi-symlink.js': ['', 34, 0, []], + '/fruits/directory/strawberry.js': ['', 33, 0, []], + '/fruits/tomato.js': ['', 32, 0, []], + }); + }); + }); + it('completes with emtpy roots', () => { process.platform = 'win32'; diff --git a/packages/jest-haste-map/src/crawlers/node.js b/packages/jest-haste-map/src/crawlers/node.js index a3370295ebcc..787299e4953d 100644 --- a/packages/jest-haste-map/src/crawlers/node.js +++ b/packages/jest-haste-map/src/crawlers/node.js @@ -22,11 +22,25 @@ function find( roots: Array, extensions: Array, ignore: IgnoreMatcher, + followSymlinks: boolean, callback: Callback, ): void { const result = []; let activeCalls = 0; + let stat = fs.lstat; + if (followSymlinks) { + stat = (file, cb) => { + fs.lstat(file, (err, stat) => { + if (stat.isSymbolicLink()) { + fs.stat(file, cb); + } else { + cb(err, stat); + } + }); + }; + } + function search(directory: string): void { activeCalls++; fs.readdir(directory, (err, names) => { @@ -39,7 +53,7 @@ function find( } activeCalls++; - fs.lstat(file, (err, stat) => { + stat(file, (err, stat) => { activeCalls--; if (!err && stat && !stat.isSymbolicLink()) { @@ -75,10 +89,18 @@ function findNative( roots: Array, extensions: Array, ignore: IgnoreMatcher, + followSymlinks: boolean, callback: Callback, ): void { const args = [].concat(roots); - args.push('-type', 'f'); + if (followSymlinks) { + args.push('('); + } + args.push('-type', 'f'); // regular files + if (followSymlinks) { + args.push('-o', '-type', 'l'); // symbolic links + args.push(')'); + } if (extensions.length) { args.push('('); } @@ -122,7 +144,14 @@ function findNative( module.exports = function nodeCrawl( options: CrawlerOptions, ): Promise { - const {data, extensions, forceNodeFilesystemAPI, ignore, roots} = options; + const { + data, + extensions, + followSymlinks, + forceNodeFilesystemAPI, + ignore, + roots, + } = options; return new Promise(resolve => { const callback = list => { @@ -143,9 +172,9 @@ module.exports = function nodeCrawl( }; if (forceNodeFilesystemAPI || process.platform === 'win32') { - find(roots, extensions, ignore, callback); + find(roots, extensions, ignore, followSymlinks, callback); } else { - findNative(roots, extensions, ignore, callback); + findNative(roots, extensions, ignore, followSymlinks, callback); } }); }; diff --git a/packages/jest-haste-map/src/index.js b/packages/jest-haste-map/src/index.js index b38df77b2ce7..9942284650e1 100644 --- a/packages/jest-haste-map/src/index.js +++ b/packages/jest-haste-map/src/index.js @@ -46,6 +46,7 @@ type Options = { cacheDirectory?: string, console?: Console, extensions: Array, + followSymlinks?: boolean, forceNodeFilesystemAPI?: boolean, hasteImplModulePath?: string, ignorePattern: HasteRegExp, @@ -65,6 +66,7 @@ type Options = { type InternalOptions = { cacheDirectory: string, extensions: Array, + followSymlinks: boolean, forceNodeFilesystemAPI: boolean, hasteImplModulePath?: string, ignorePattern: HasteRegExp, @@ -207,6 +209,7 @@ class HasteMap extends EventEmitter { this._options = { cacheDirectory: options.cacheDirectory || os.tmpdir(), extensions: options.extensions, + followSymlinks: !!options.followSymlinks, forceNodeFilesystemAPI: !!options.forceNodeFilesystemAPI, hasteImplModulePath: options.hasteImplModulePath, ignorePattern: options.ignorePattern, @@ -224,6 +227,12 @@ class HasteMap extends EventEmitter { watch: !!options.watch, }; this._console = options.console || global.console; + if (this._options.followSymlinks && this._options.useWatchman) { + this._console.warn( + 'watchman can not be used with followSymlinks, watchman will be disabled', + ); + this._options.useWatchman = false; + } if (!(options.ignorePattern instanceof RegExp)) { this._console.warn( 'jest-haste-map: the `ignorePattern` options as a function is being ' + @@ -537,6 +546,7 @@ class HasteMap extends EventEmitter { return nodeCrawl({ data: hasteMap, extensions: options.extensions, + followSymlinks: options.followSymlinks, forceNodeFilesystemAPI: options.forceNodeFilesystemAPI, ignore, roots: options.roots, @@ -556,6 +566,7 @@ class HasteMap extends EventEmitter { return crawl({ data: hasteMap, extensions: options.extensions, + followSymlinks: options.followSymlinks, forceNodeFilesystemAPI: options.forceNodeFilesystemAPI, ignore, roots: options.roots, diff --git a/packages/jest-haste-map/src/types.js b/packages/jest-haste-map/src/types.js index 28acc7d7063d..c63baf2675f7 100644 --- a/packages/jest-haste-map/src/types.js +++ b/packages/jest-haste-map/src/types.js @@ -30,6 +30,7 @@ export type WorkerCallback = ( export type CrawlerOptions = {| data: InternalHasteMap, extensions: Array, + followSymlinks: boolean, forceNodeFilesystemAPI: boolean, ignore: IgnoreMatcher, roots: Array, diff --git a/packages/jest-runtime/src/cli/index.js b/packages/jest-runtime/src/cli/index.js index c5cc971c59af..357069ad9b4d 100644 --- a/packages/jest-runtime/src/cli/index.js +++ b/packages/jest-runtime/src/cli/index.js @@ -64,6 +64,7 @@ function run(cliArgv?: Argv, cliInfo?: Array) { unmockedModulePathPatterns: null, }); Runtime.createContext(config, { + followSymlinks: globalConfig.followSymlinks, maxWorkers: os.cpus().length - 1, watchman: globalConfig.watchman, }) diff --git a/packages/jest-runtime/src/index.js b/packages/jest-runtime/src/index.js index 51b54ece9f4d..369a92d4f2f0 100644 --- a/packages/jest-runtime/src/index.js +++ b/packages/jest-runtime/src/index.js @@ -41,6 +41,7 @@ type Module = {| type HasteMapOptions = {| console?: Console, + followSymlinks: boolean, maxWorkers: number, resetCache: boolean, watch?: boolean, @@ -195,6 +196,7 @@ class Runtime { config: ProjectConfig, options: { console?: Console, + followSymlinks: boolean, maxWorkers: number, watch?: boolean, watchman: boolean, @@ -203,6 +205,7 @@ class Runtime { createDirectory(config.cacheDirectory); const instance = Runtime.createHasteMap(config, { console: options.console, + followSymlinks: options.followSymlinks, maxWorkers: options.maxWorkers, resetCache: !config.cache, watch: options.watch, @@ -233,6 +236,7 @@ class Runtime { cacheDirectory: config.cacheDirectory, console: options && options.console, extensions: [SNAPSHOT_EXTENSION].concat(config.moduleFileExtensions), + followSymlinks: options && options.followSymlinks, hasteImplModulePath: config.haste.hasteImplModulePath, ignorePattern, maxWorkers: (options && options.maxWorkers) || 1, diff --git a/packages/jest-validate/src/__tests__/fixtures/jest_config.js b/packages/jest-validate/src/__tests__/fixtures/jest_config.js index 51139fca72ba..ff1817d1dd06 100644 --- a/packages/jest-validate/src/__tests__/fixtures/jest_config.js +++ b/packages/jest-validate/src/__tests__/fixtures/jest_config.js @@ -30,6 +30,7 @@ const defaultConfig = { coveragePathIgnorePatterns: [NODE_MODULES_REGEXP], coverageReporters: ['json', 'text', 'lcov', 'clover'], expand: false, + followSymlinks: false, globals: {}, haste: { providesModuleNodeModules: [], @@ -79,6 +80,7 @@ const validConfig = { }, }, expand: false, + followSymlinks: false, forceExit: false, globals: {}, haste: { diff --git a/test_utils.js b/test_utils.js index 45c5aef89582..9d8ea5b79b2b 100644 --- a/test_utils.js +++ b/test_utils.js @@ -23,6 +23,7 @@ const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { coverageThreshold: {global: {}}, expand: false, findRelatedTests: false, + followSymlinks: false, forceExit: false, json: false, lastCommit: false, diff --git a/types/Argv.js b/types/Argv.js index fbb5f0ee9e36..6c604743e365 100644 --- a/types/Argv.js +++ b/types/Argv.js @@ -33,6 +33,7 @@ export type Argv = {| env: string, expand: boolean, findRelatedTests: boolean, + followSymlinks: boolean, forceExit: boolean, globals: string, h: boolean, diff --git a/types/Config.js b/types/Config.js index 33e809f8d7a7..30770cde77c7 100644 --- a/types/Config.js +++ b/types/Config.js @@ -32,6 +32,7 @@ export type DefaultOptions = {| coveragePathIgnorePatterns: Array, coverageReporters: Array, expand: boolean, + followSymlinks: boolean, globals: ConfigGlobals, haste: HasteConfig, mapCoverage: boolean, @@ -80,6 +81,7 @@ export type InitialOptions = { displayName?: string, expand?: boolean, findRelatedTests?: boolean, + followSymlinks?: boolean, forceExit?: boolean, json?: boolean, globals?: ConfigGlobals, @@ -151,6 +153,7 @@ export type GlobalConfig = {| coverageThreshold: {global: {[key: string]: number}}, expand: boolean, findRelatedTests: boolean, + followSymlinks: boolean, forceExit: boolean, json: boolean, lastCommit: boolean,