Skip to content

Commit 6d45caa

Browse files
mrmekuDan Muller
andauthored
feat(jest-haste-map): Enable crawling for symlink test files (#9351)
Co-authored-by: Dan Muller <[email protected]>
1 parent bc818b5 commit 6d45caa

File tree

12 files changed

+217
-10
lines changed

12 files changed

+217
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- `[jest-environment-node]` Add AbortController to globals ([#11182](https://github.com/facebook/jest/pull/11182))
1919
- `[@jest/fake-timers]` Update to `@sinonjs/fake-timers` to v7 ([#11198](https://github.com/facebook/jest/pull/11198))
2020
- `[jest-haste-map]` Handle injected scm clocks ([#10966](https://github.com/facebook/jest/pull/10966))
21+
- `[jest-haste-map]` Add `enableSymlinks` configuration option to follow symlinks for test files ([#9351](https://github.com/facebook/jest/pull/9351))
2122
- `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751))
2223
- `[jest-runner]` [**BREAKING**] set exit code to 1 if test logs after teardown ([#10728](https://github.com/facebook/jest/pull/10728))
2324
- `[jest-runner]` [**BREAKING**] Run transforms over `runnner` ([#8823](https://github.com/facebook/jest/pull/8823))

docs/Configuration.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -491,15 +491,21 @@ This will be used to configure the behavior of `jest-haste-map`, Jest's internal
491491

492492
```ts
493493
type HasteConfig = {
494-
// Whether to hash files using SHA-1.
494+
/** Whether to hash files using SHA-1. */
495495
computeSha1?: boolean;
496-
// The platform to use as the default, e.g. 'ios'.
496+
/** The platform to use as the default, e.g. 'ios'. */
497497
defaultPlatform?: string | null;
498-
// Path to a custom implementation of Haste.
498+
/**
499+
* Whether to follow symlinks when crawling for files.
500+
* This options cannot be used in projects which use watchman.
501+
* Projects with `watchman` set to true will error if this option is set to true.
502+
*/
503+
enableSymlinks?: boolean;
504+
/** Path to a custom implementation of Haste. */
499505
hasteImplModulePath?: string;
500-
// All platforms to target, e.g ['ios', 'android'].
506+
/** All platforms to target, e.g ['ios', 'android']. */
501507
platforms?: Array<string>;
502-
// Whether to throw on error on module collision.
508+
/** Whether to throw on error on module collision. */
503509
throwOnModuleCollision?: boolean;
504510
};
505511
```

e2e/__tests__/crawlSymlinks.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
import {tmpdir} from 'os';
8+
import * as path from 'path';
9+
import {wrap} from 'jest-snapshot-serializer-raw';
10+
import {cleanup, writeFiles, writeSymlinks} from '../Utils';
11+
import runJest from '../runJest';
12+
13+
const DIR = path.resolve(tmpdir(), 'crawl-symlinks-test');
14+
15+
beforeEach(() => {
16+
cleanup(DIR);
17+
18+
writeFiles(DIR, {
19+
'package.json': JSON.stringify({
20+
jest: {
21+
testMatch: ['<rootDir>/test-files/test.js'],
22+
},
23+
}),
24+
'symlinked-files/test.js': `
25+
test('1+1', () => {
26+
expect(1).toBe(1);
27+
});
28+
`,
29+
});
30+
31+
writeSymlinks(DIR, {
32+
'symlinked-files/test.js': 'test-files/test.js',
33+
});
34+
});
35+
36+
afterEach(() => {
37+
cleanup(DIR);
38+
});
39+
40+
test('Node crawler picks up symlinked files when option is set as flag', () => {
41+
// Symlinks are only enabled on windows with developer mode.
42+
// https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10/
43+
if (process.platform === 'win32') {
44+
return;
45+
}
46+
47+
const {stdout, stderr, exitCode} = runJest(DIR, [
48+
'--haste={"enableSymlinks": true}',
49+
'--no-watchman',
50+
]);
51+
52+
expect(stdout).toEqual('');
53+
expect(stderr).toContain('Test Suites: 1 passed, 1 total');
54+
expect(exitCode).toEqual(0);
55+
});
56+
57+
test('Node crawler does not pick up symlinked files by default', () => {
58+
const {stdout, stderr, exitCode} = runJest(DIR, ['--no-watchman']);
59+
expect(stdout).toContain('No tests found, exiting with code 1');
60+
expect(stderr).toEqual('');
61+
expect(exitCode).toEqual(1);
62+
});
63+
64+
test('Should throw if watchman used with haste.enableSymlinks', () => {
65+
// it should throw both if watchman is explicitly provided and not
66+
const run1 = runJest(DIR, ['--haste={"enableSymlinks": true}']);
67+
const run2 = runJest(DIR, ['--haste={"enableSymlinks": true}', '--watchman']);
68+
69+
expect(run1.exitCode).toEqual(run2.exitCode);
70+
expect(run1.stderr).toEqual(run2.stderr);
71+
expect(run1.stdout).toEqual(run2.stdout);
72+
73+
const {exitCode, stderr, stdout} = run1;
74+
75+
expect(stdout).toEqual('');
76+
expect(wrap(stderr)).toMatchInlineSnapshot(`
77+
Validation Error:
78+
79+
haste.enableSymlinks is incompatible with watchman
80+
81+
Either set haste.enableSymlinks to false or do not use watchman
82+
`);
83+
expect(exitCode).toEqual(1);
84+
});

packages/jest-config/src/ValidConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const initialOptions: Config.InitialOptions = {
5555
haste: {
5656
computeSha1: true,
5757
defaultPlatform: 'ios',
58+
enableSymlinks: false,
5859
hasteImplModulePath: '<rootDir>/haste_impl.js',
5960
platforms: ['ios', 'android'],
6061
throwOnModuleCollision: false,

packages/jest-config/src/__tests__/normalize.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,3 +1842,26 @@ describe('extensionsToTreatAsEsm', () => {
18421842
);
18431843
});
18441844
});
1845+
1846+
describe('haste.enableSymlinks', () => {
1847+
it('should throw if watchman is not disabled', async () => {
1848+
await expect(
1849+
normalize({haste: {enableSymlinks: true}, rootDir: '/root/'}, {}),
1850+
).rejects.toThrow('haste.enableSymlinks is incompatible with watchman');
1851+
1852+
await expect(
1853+
normalize(
1854+
{haste: {enableSymlinks: true}, rootDir: '/root/', watchman: true},
1855+
{},
1856+
),
1857+
).rejects.toThrow('haste.enableSymlinks is incompatible with watchman');
1858+
1859+
const {options} = await normalize(
1860+
{haste: {enableSymlinks: true}, rootDir: '/root/', watchman: false},
1861+
{},
1862+
);
1863+
1864+
expect(options.haste.enableSymlinks).toBe(true);
1865+
expect(options.watchman).toBe(false);
1866+
});
1867+
});

packages/jest-config/src/normalize.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,10 @@ export default async function normalize(
648648

649649
validateExtensionsToTreatAsEsm(options.extensionsToTreatAsEsm);
650650

651+
if (options.watchman == null) {
652+
options.watchman = DEFAULT_CONFIG.watchman;
653+
}
654+
651655
const optionKeys = Object.keys(options) as Array<keyof Config.InitialOptions>;
652656

653657
optionKeys.reduce((newOptions, key: keyof Config.InitialOptions) => {
@@ -1023,6 +1027,14 @@ export default async function normalize(
10231027
return newOptions;
10241028
}, newOptions);
10251029

1030+
if (options.watchman && options.haste?.enableSymlinks) {
1031+
throw new ValidationError(
1032+
'Validation Error',
1033+
'haste.enableSymlinks is incompatible with watchman',
1034+
'Either set haste.enableSymlinks to false or do not use watchman',
1035+
);
1036+
}
1037+
10261038
newOptions.roots.forEach((root, i) => {
10271039
verifyDirectoryExists(root, `roots[${i}]`);
10281040
});

packages/jest-haste-map/src/__tests__/index.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ let mockChangedFiles;
8484
let mockFs;
8585

8686
jest.mock('graceful-fs', () => ({
87+
existsSync: jest.fn(path => {
88+
// A file change can be triggered by writing into the
89+
// mockChangedFiles object.
90+
if (mockChangedFiles && path in mockChangedFiles) {
91+
return true;
92+
}
93+
94+
if (mockFs[path]) {
95+
return true;
96+
}
97+
98+
return false;
99+
}),
87100
readFileSync: jest.fn((path, options) => {
88101
// A file change can be triggered by writing into the
89102
// mockChangedFiles object.
@@ -494,6 +507,42 @@ describe('HasteMap', () => {
494507
expect(useBuitinsInContext(hasteMap.read())).toEqual(data);
495508
});
496509

510+
it('throws if both symlinks and watchman is enabled', () => {
511+
expect(
512+
() => new HasteMap({...defaultConfig, enableSymlinks: true}),
513+
).toThrow(
514+
'Set either `enableSymlinks` to false or `useWatchman` to false.',
515+
);
516+
expect(
517+
() =>
518+
new HasteMap({
519+
...defaultConfig,
520+
enableSymlinks: true,
521+
useWatchman: true,
522+
}),
523+
).toThrow(
524+
'Set either `enableSymlinks` to false or `useWatchman` to false.',
525+
);
526+
527+
expect(
528+
() =>
529+
new HasteMap({
530+
...defaultConfig,
531+
enableSymlinks: false,
532+
useWatchman: true,
533+
}),
534+
).not.toThrow();
535+
536+
expect(
537+
() =>
538+
new HasteMap({
539+
...defaultConfig,
540+
enableSymlinks: true,
541+
useWatchman: false,
542+
}),
543+
).not.toThrow();
544+
});
545+
497546
describe('builds a haste map on a fresh cache with SHA-1s', () => {
498547
it.each([false, true])('uses watchman: %s', async useWatchman => {
499548
const node = require('../crawlers/node');

packages/jest-haste-map/src/crawlers/node.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function find(
6060
roots: Array<string>,
6161
extensions: Array<string>,
6262
ignore: IgnoreMatcher,
63+
enableSymlinks: boolean,
6364
callback: Callback,
6465
): void {
6566
const result: Result = [];
@@ -98,7 +99,9 @@ function find(
9899

99100
activeCalls++;
100101

101-
fs.lstat(file, (err, stat) => {
102+
const stat = enableSymlinks ? fs.stat : fs.lstat;
103+
104+
stat(file, (err, stat) => {
102105
activeCalls--;
103106

104107
// This logic is unnecessary for node > v10.10, but leaving it in
@@ -137,10 +140,16 @@ function findNative(
137140
roots: Array<string>,
138141
extensions: Array<string>,
139142
ignore: IgnoreMatcher,
143+
enableSymlinks: boolean,
140144
callback: Callback,
141145
): void {
142146
const args = Array.from(roots);
143-
args.push('-type', 'f');
147+
if (enableSymlinks) {
148+
args.push('(', '-type', 'f', '-o', '-type', 'l', ')');
149+
} else {
150+
args.push('-type', 'f');
151+
}
152+
144153
if (extensions.length) {
145154
args.push('(');
146155
}
@@ -177,7 +186,8 @@ function findNative(
177186
} else {
178187
lines.forEach(path => {
179188
fs.stat(path, (err, stat) => {
180-
if (!err && stat) {
189+
// Filter out symlinks that describe directories
190+
if (!err && stat && !stat.isDirectory()) {
181191
result.push([path, stat.mtime.getTime(), stat.size]);
182192
}
183193
if (--count === 0) {
@@ -201,6 +211,7 @@ export = async function nodeCrawl(
201211
forceNodeFilesystemAPI,
202212
ignore,
203213
rootDir,
214+
enableSymlinks,
204215
roots,
205216
} = options;
206217

@@ -231,9 +242,9 @@ export = async function nodeCrawl(
231242
};
232243

233244
if (useNativeFind) {
234-
findNative(roots, extensions, ignore, callback);
245+
findNative(roots, extensions, ignore, enableSymlinks, callback);
235246
} else {
236-
find(roots, extensions, ignore, callback);
247+
find(roots, extensions, ignore, enableSymlinks, callback);
237248
}
238249
});
239250
};

packages/jest-haste-map/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type Options = {
5656
computeSha1?: boolean;
5757
console?: Console;
5858
dependencyExtractor?: string | null;
59+
enableSymlinks?: boolean;
5960
extensions: Array<string>;
6061
forceNodeFilesystemAPI?: boolean;
6162
hasteImplModulePath?: string;
@@ -79,6 +80,7 @@ type InternalOptions = {
7980
computeDependencies: boolean;
8081
computeSha1: boolean;
8182
dependencyExtractor: string | null;
83+
enableSymlinks: boolean;
8284
extensions: Array<string>;
8385
forceNodeFilesystemAPI: boolean;
8486
hasteImplModulePath?: string;
@@ -227,6 +229,7 @@ export default class HasteMap extends EventEmitter {
227229
: options.computeDependencies,
228230
computeSha1: options.computeSha1 || false,
229231
dependencyExtractor: options.dependencyExtractor || null,
232+
enableSymlinks: options.enableSymlinks || false,
230233
extensions: options.extensions,
231234
forceNodeFilesystemAPI: !!options.forceNodeFilesystemAPI,
232235
hasteImplModulePath: options.hasteImplModulePath,
@@ -262,6 +265,14 @@ export default class HasteMap extends EventEmitter {
262265
this._options.ignorePattern = new RegExp(VCS_DIRECTORIES);
263266
}
264267

268+
if (this._options.enableSymlinks && this._options.useWatchman) {
269+
throw new Error(
270+
'jest-haste-map: enableSymlinks config option was set, but ' +
271+
'is incompatible with watchman.\n' +
272+
'Set either `enableSymlinks` to false or `useWatchman` to false.',
273+
);
274+
}
275+
265276
const rootDirHash = createHash('md5').update(options.rootDir).digest('hex');
266277
let hasteImplHash = '';
267278
let dependencyExtractorHash = '';
@@ -725,6 +736,7 @@ export default class HasteMap extends EventEmitter {
725736
const crawlerOptions: CrawlerOptions = {
726737
computeSha1: options.computeSha1,
727738
data: hasteMap,
739+
enableSymlinks: options.enableSymlinks,
728740
extensions: options.extensions,
729741
forceNodeFilesystemAPI: options.forceNodeFilesystemAPI,
730742
ignore,

packages/jest-haste-map/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type WorkerMetadata = {
3030

3131
export type CrawlerOptions = {
3232
computeSha1: boolean;
33+
enableSymlinks: boolean;
3334
data: InternalHasteMap;
3435
extensions: Array<string>;
3536
forceNodeFilesystemAPI: boolean;

packages/jest-runtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export default class Runtime {
319319
computeSha1: config.haste.computeSha1,
320320
console: options && options.console,
321321
dependencyExtractor: config.dependencyExtractor,
322+
enableSymlinks: config.haste.enableSymlinks,
322323
extensions: [Snapshot.EXTENSION].concat(config.moduleFileExtensions),
323324
hasteImplModulePath: config.haste.hasteImplModulePath,
324325
ignorePattern,

packages/jest-types/src/Config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export type HasteConfig = {
2222
computeSha1?: boolean;
2323
/** The platform to use as the default, e.g. 'ios'. */
2424
defaultPlatform?: string | null;
25+
/**
26+
* Whether to follow symlinks when crawling for files.
27+
* This options cannot be used in projects which use watchman.
28+
* Projects with `watchman` set to true will error if this option is set to true.
29+
*/
30+
enableSymlinks?: boolean;
2531
/** Path to a custom implementation of Haste. */
2632
hasteImplModulePath?: string;
2733
/** All platforms to target, e.g ['ios', 'android']. */

0 commit comments

Comments
 (0)