Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(jest-haste-map): Enable crawling for symlink test files #9351

Merged
merged 14 commits into from Apr 2, 2021
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -3,6 +3,7 @@
### Features

- `[jest-core]` Add `findRelatedTests` and `nonFlagArgs` in allowed config options for `updateConfigAndRun` in watch plugins ([#10659](https://github.com/facebook/jest/pull/10659))
- `[jest-haste-map]` Add `enableSymlinks` configuration option to follow symlinks for test files ([#9350](https://github.com/facebook/jest/issues/9350))

### Fixes

Expand Down
16 changes: 11 additions & 5 deletions docs/Configuration.md
Expand Up @@ -472,15 +472,21 @@ This will be used to configure the behavior of `jest-haste-map`, Jest's internal

```ts
type HasteConfig = {
// Whether to hash files using SHA-1.
/** Whether to hash files using SHA-1. */
computeSha1?: boolean;
// The platform to use as the default, e.g. 'ios'.
/** The platform to use as the default, e.g. 'ios'. */
defaultPlatform?: string | null;
// Path to a custom implementation of Haste.
/**
* Whether to follow symlinks when crawling for files.
* This options cannot be used in projects which use watchman.
* Projects with `watchman` set to true will error if this option is set to true.
*/
enableSymlinks?: boolean;
/** Path to a custom implementation of Haste. */
hasteImplModulePath?: string;
// All platforms to target, e.g ['ios', 'android'].
/** All platforms to target, e.g ['ios', 'android']. */
platforms?: Array<string>;
// Whether to throw on error on module collision.
/** Whether to throw on error on module collision. */
throwOnModuleCollision?: boolean;
};
```
Expand Down
96 changes: 96 additions & 0 deletions e2e/__tests__/crawlSymlinks.test.ts
@@ -0,0 +1,96 @@
/**
* 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 {tmpdir} from 'os';
import * as path from 'path';
SimenB marked this conversation as resolved.
Show resolved Hide resolved
import {wrap} from 'jest-snapshot-serializer-raw';
import {cleanup, writeFiles, writeSymlinks} from '../Utils';
import runJest from '../runJest';

const DIR = path.resolve(tmpdir(), 'crawl-symlinks-test');

beforeEach(() => {
cleanup(DIR);
});

afterEach(() => {
cleanup(DIR);
});

function init(
extraFiles: {
[filename: string]: string;
} = {},
) {
writeFiles(DIR, {
'package.json': JSON.stringify({
jest: {
testMatch: ['<rootDir>/test-files/test.js'],
},
}),
'symlinked-files/test.js': `
test('1+1', () => {
expect(1).toBe(1);
});
`,
...extraFiles,
});

writeSymlinks(DIR, {
'symlinked-files/test.js': 'test-files/test.js',
});
}

const noWatchman = '--no-watchman';
test('Node crawler picks up symlinked files when option is set as flag', () => {
// Symlinks are only enabled on windows with developer mode.
// https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10/
if (process.platform === 'win32') {
return;
}

init();
const {stdout, stderr, exitCode} = runJest(DIR, [
'--haste={"enableSymlinks": true}',
noWatchman,
]);

expect(stdout).toEqual('');
expect(stderr).toContain('Test Suites: 1 passed, 1 total');
expect(exitCode).toEqual(0);
});

test('Node crawler does not pick up symlinked files by default', () => {
init();
const {stdout, stderr, exitCode} = runJest(DIR, [noWatchman]);
expect(stdout).toContain('No tests found, exiting with code 1');
expect(stderr).toEqual('');
expect(exitCode).toEqual(1);
});

test('Should throw if watchman used with haste.enableSymlinks', () => {
init({'.watchmanconfig': JSON.stringify({})});

// it should throw both if watchman is explicitly provided and not
const run1 = runJest(DIR, ['--haste={"enableSymlinks": true}']);
const run2 = runJest(DIR, ['--haste={"enableSymlinks": true}', '--watchman']);

expect(run1.exitCode).toEqual(run2.exitCode);
expect(run1.stderr).toEqual(run2.stderr);
expect(run1.stdout).toEqual(run2.stdout);

const {exitCode, stderr, stdout} = run1;

expect(stdout).toEqual('');
expect(wrap(stderr)).toMatchInlineSnapshot(`
Validation Error:

haste.enableSymlinks is incompatible with watchman

Either set haste.enableSymlinks to false or do not use watchman
`);
expect(exitCode).toEqual(1);
});
1 change: 1 addition & 0 deletions packages/jest-config/src/ValidConfig.ts
Expand Up @@ -54,6 +54,7 @@ const initialOptions: Config.InitialOptions = {
haste: {
computeSha1: true,
defaultPlatform: 'ios',
enableSymlinks: false,
hasteImplModulePath: '<rootDir>/haste_impl.js',
platforms: ['ios', 'android'],
throwOnModuleCollision: false,
Expand Down
23 changes: 23 additions & 0 deletions packages/jest-config/src/__tests__/normalize.test.js
Expand Up @@ -1714,3 +1714,26 @@ describe('testTimeout', () => {
).toThrowErrorMatchingSnapshot();
});
});

describe('haste.enableSymlinks', () => {
it('should throw if watchman is not disabled', () => {
expect(() =>
normalize({haste: {enableSymlinks: true}, rootDir: '/root/'}, {}),
).toThrow('haste.enableSymlinks is incompatible with watchman');

expect(() =>
normalize(
{haste: {enableSymlinks: true}, rootDir: '/root/', watchman: true},
{},
),
).toThrow('haste.enableSymlinks is incompatible with watchman');

const {options} = normalize(
{haste: {enableSymlinks: true}, rootDir: '/root/', watchman: false},
{},
);

expect(options.haste.enableSymlinks).toBe(true);
expect(options.watchman).toBe(false);
});
});
12 changes: 12 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -577,6 +577,10 @@ export default function normalize(
});
}

if (options.watchman == null) {
options.watchman = DEFAULT_CONFIG.watchman;
}

const optionKeys = Object.keys(options) as Array<keyof Config.InitialOptions>;

optionKeys.reduce((newOptions, key: keyof Config.InitialOptions) => {
Expand Down Expand Up @@ -952,6 +956,14 @@ export default function normalize(
return newOptions;
}, newOptions);

if (options.watchman && options.haste?.enableSymlinks) {
throw new ValidationError(
'Validation Error',
'haste.enableSymlinks is incompatible with watchman',
'Either set haste.enableSymlinks to false or do not use watchman',
);
}

newOptions.roots.forEach((root, i) => {
verifyDirectoryExists(root, `roots[${i}]`);
});
Expand Down
49 changes: 49 additions & 0 deletions packages/jest-haste-map/src/__tests__/index.test.js
Expand Up @@ -88,6 +88,19 @@ let mockChangedFiles;
let mockFs;

jest.mock('graceful-fs', () => ({
existsSync: jest.fn(path => {
// A file change can be triggered by writing into the
// mockChangedFiles object.
if (mockChangedFiles && path in mockChangedFiles) {
return true;
}

if (mockFs[path]) {
return true;
}

return false;
}),
readFileSync: jest.fn((path, options) => {
// A file change can be triggered by writing into the
// mockChangedFiles object.
Expand Down Expand Up @@ -504,6 +517,42 @@ describe('HasteMap', () => {
});
});

it('throws if both symlinks and watchman is enabled', () => {
expect(
() => new HasteMap({...defaultConfig, enableSymlinks: true}),
).toThrow(
'Set either `enableSymlinks` to false or `useWatchman` to false.',
);
expect(
() =>
new HasteMap({
...defaultConfig,
enableSymlinks: true,
useWatchman: true,
}),
).toThrow(
'Set either `enableSymlinks` to false or `useWatchman` to false.',
);

expect(
() =>
new HasteMap({
...defaultConfig,
enableSymlinks: false,
useWatchman: true,
}),
).not.toThrow();

expect(
() =>
new HasteMap({
...defaultConfig,
enableSymlinks: true,
useWatchman: false,
}),
).not.toThrow();
});

describe('builds a haste map on a fresh cache with SHA-1s', () => {
[false, true].forEach(useWatchman => {
it('uses watchman: ' + useWatchman, async () => {
Expand Down
21 changes: 16 additions & 5 deletions packages/jest-haste-map/src/crawlers/node.ts
Expand Up @@ -61,6 +61,7 @@ function find(
extensions: Array<string>,
ignore: IgnoreMatcher,
callback: Callback,
enableSymlinks: boolean,
): void {
const result: Result = [];
let activeCalls = 0;
Expand Down Expand Up @@ -98,7 +99,9 @@ function find(

activeCalls++;

fs.lstat(file, (err, stat) => {
const stat = enableSymlinks ? fs.stat : fs.lstat;

stat(file, (err, stat) => {
activeCalls--;

// This logic is unnecessary for node > v10.10, but leaving it in
Expand Down Expand Up @@ -138,9 +141,15 @@ function findNative(
extensions: Array<string>,
ignore: IgnoreMatcher,
callback: Callback,
enableSymlinks: boolean,
): void {
const args = Array.from(roots);
args.push('-type', 'f');
if (enableSymlinks) {
args.push('(', '-type', 'f', '-o', '-type', 'l', ')');
} else {
args.push('-type', 'f');
}

if (extensions.length) {
args.push('(');
}
Expand Down Expand Up @@ -177,7 +186,8 @@ function findNative(
} else {
lines.forEach(path => {
fs.stat(path, (err, stat) => {
if (!err && stat) {
// Filter out symlinks that describe directories
if (!err && stat && !stat.isDirectory()) {
result.push([path, stat.mtime.getTime(), stat.size]);
}
if (--count === 0) {
Expand All @@ -201,6 +211,7 @@ export = async function nodeCrawl(
forceNodeFilesystemAPI,
ignore,
rootDir,
enableSymlinks,
roots,
} = options;

Expand Down Expand Up @@ -231,9 +242,9 @@ export = async function nodeCrawl(
};

if (useNativeFind) {
findNative(roots, extensions, ignore, callback);
findNative(roots, extensions, ignore, callback, enableSymlinks);
} else {
find(roots, extensions, ignore, callback);
find(roots, extensions, ignore, callback, enableSymlinks);
}
});
};
12 changes: 12 additions & 0 deletions packages/jest-haste-map/src/index.ts
Expand Up @@ -59,6 +59,7 @@ type Options = {
computeSha1?: boolean;
console?: Console;
dependencyExtractor?: string | null;
enableSymlinks?: boolean;
extensions: Array<string>;
forceNodeFilesystemAPI?: boolean;
hasteImplModulePath?: string;
Expand All @@ -82,6 +83,7 @@ type InternalOptions = {
computeDependencies: boolean;
computeSha1: boolean;
dependencyExtractor: string | null;
enableSymlinks: boolean;
extensions: Array<string>;
forceNodeFilesystemAPI: boolean;
hasteImplModulePath?: string;
Expand Down Expand Up @@ -234,6 +236,7 @@ class HasteMap extends EventEmitter {
: options.computeDependencies,
computeSha1: options.computeSha1 || false,
dependencyExtractor: options.dependencyExtractor || null,
enableSymlinks: options.enableSymlinks || false,
extensions: options.extensions,
forceNodeFilesystemAPI: !!options.forceNodeFilesystemAPI,
hasteImplModulePath: options.hasteImplModulePath,
Expand Down Expand Up @@ -275,6 +278,14 @@ class HasteMap extends EventEmitter {
this._options.ignorePattern = new RegExp(VCS_DIRECTORIES);
}

if (this._options.enableSymlinks && this._options.useWatchman) {
throw new Error(
'jest-haste-map: enableSymlinks config option was set, but ' +
'is incompatible with watchman.\n' +
'Set either `enableSymlinks` to false or `useWatchman` to false.',
);
}

const rootDirHash = createHash('md5').update(options.rootDir).digest('hex');
let hasteImplHash = '';
let dependencyExtractorHash = '';
Expand Down Expand Up @@ -738,6 +749,7 @@ class HasteMap extends EventEmitter {
const crawlerOptions: CrawlerOptions = {
computeSha1: options.computeSha1,
data: hasteMap,
enableSymlinks: options.enableSymlinks,
extensions: options.extensions,
forceNodeFilesystemAPI: options.forceNodeFilesystemAPI,
ignore,
Expand Down
1 change: 1 addition & 0 deletions packages/jest-haste-map/src/types.ts
Expand Up @@ -30,6 +30,7 @@ export type WorkerMetadata = {

export type CrawlerOptions = {
computeSha1: boolean;
enableSymlinks: boolean;
data: InternalHasteMap;
extensions: Array<string>;
forceNodeFilesystemAPI: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -321,6 +321,7 @@ class Runtime {
computeSha1: config.haste.computeSha1,
console: options && options.console,
dependencyExtractor: config.dependencyExtractor,
enableSymlinks: config.haste.enableSymlinks,
extensions: [Snapshot.EXTENSION].concat(config.moduleFileExtensions),
hasteImplModulePath: config.haste.hasteImplModulePath,
ignorePattern,
Expand Down
6 changes: 6 additions & 0 deletions packages/jest-types/src/Config.ts
Expand Up @@ -22,6 +22,12 @@ export type HasteConfig = {
computeSha1?: boolean;
/** The platform to use as the default, e.g. 'ios'. */
defaultPlatform?: string | null;
/**
* Whether to follow symlinks when crawling for files.
* This options cannot be used in projects which use watchman.
* Projects with `watchman` set to true will error if this option is set to true.
*/
enableSymlinks?: boolean;
/** Path to a custom implementation of Haste. */
hasteImplModulePath?: string;
/** All platforms to target, e.g ['ios', 'android']. */
Expand Down