Skip to content

Commit 504cace

Browse files
lencionicpojer
andauthored
Improve Jest startup time and test runtime, particularly when running with coverage, by caching micromatch and avoiding recreating RegExp instances (#10131)
Co-authored-by: Christoph Nakazawa <[email protected]>
1 parent 4471bbb commit 504cace

File tree

9 files changed

+244
-19
lines changed

9 files changed

+244
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
### Performance
2626

27+
- `[jest-core, jest-transform, jest-haste-map]` Improve Jest startup time and test runtime, particularly when running with coverage, by caching micromatch and avoiding recreating RegExp instances ([#10131](https://github.com/facebook/jest/pull/10131))
28+
2729
## 26.0.1
2830

2931
### Fixes

packages/jest-core/src/SearchSource.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import DependencyResolver = require('jest-resolve-dependencies');
1616
import {escapePathForRegex} from 'jest-regex-util';
1717
import {replaceRootDirInPath} from 'jest-config';
1818
import {buildSnapshotResolver} from 'jest-snapshot';
19-
import {replacePathSepForGlob, testPathPatternToRegExp} from 'jest-util';
19+
import {globsToMatcher, testPathPatternToRegExp} from 'jest-util';
2020
import type {Filter, Stats, TestPathCases} from './types';
2121

2222
export type SearchResult = {
@@ -37,12 +37,19 @@ export type TestSelectionConfig = {
3737
watch?: boolean;
3838
};
3939

40-
const globsToMatcher = (globs: Array<Config.Glob>) => (path: Config.Path) =>
41-
micromatch([replacePathSepForGlob(path)], globs, {dot: true}).length > 0;
40+
const regexToMatcher = (testRegex: Config.ProjectConfig['testRegex']) => {
41+
const regexes = testRegex.map(testRegex => new RegExp(testRegex));
4242

43-
const regexToMatcher = (testRegex: Config.ProjectConfig['testRegex']) => (
44-
path: Config.Path,
45-
) => testRegex.some(testRegex => new RegExp(testRegex).test(path));
43+
return (path: Config.Path) =>
44+
regexes.some(regex => {
45+
const result = regex.test(path);
46+
47+
// prevent stateful regexes from breaking, just in case
48+
regex.lastIndex = 0;
49+
50+
return result;
51+
});
52+
};
4653

4754
const toTests = (context: Context, tests: Array<Config.Path>) =>
4855
tests.map(path => ({

packages/jest-core/src/__tests__/SearchSource.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,34 @@ describe('SearchSource', () => {
206206
});
207207
});
208208

209+
it('finds tests matching a JS with overriding glob patterns', () => {
210+
const {options: config} = normalize(
211+
{
212+
moduleFileExtensions: ['js', 'jsx'],
213+
name,
214+
rootDir,
215+
testMatch: [
216+
'**/*.js?(x)',
217+
'!**/test.js?(x)',
218+
'**/test.js',
219+
'!**/test.js',
220+
],
221+
testRegex: '',
222+
},
223+
{} as Config.Argv,
224+
);
225+
226+
return findMatchingTests(config).then(data => {
227+
const relPaths = toPaths(data.tests).map(absPath =>
228+
path.relative(rootDir, absPath),
229+
);
230+
expect(relPaths.sort()).toEqual([
231+
path.normalize('module.jsx'),
232+
path.normalize('no_tests.js'),
233+
]);
234+
});
235+
});
236+
209237
it('finds tests with default file extensions using testRegex', () => {
210238
const {options: config} = normalize(
211239
{

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import micromatch = require('micromatch');
9-
import {replacePathSepForGlob} from 'jest-util';
8+
import {globsToMatcher, replacePathSepForGlob} from 'jest-util';
109
import type {Config} from '@jest/types';
1110
import type {FileData} from './types';
1211
import * as fastPath from './lib/fast_path';
@@ -84,9 +83,11 @@ export default class HasteFS {
8483
root: Config.Path | null,
8584
): Set<Config.Path> {
8685
const files = new Set<string>();
86+
const matcher = globsToMatcher(globs);
87+
8788
for (const file of this.getAbsoluteFileIterator()) {
8889
const filePath = root ? fastPath.relative(root, file) : file;
89-
if (micromatch([replacePathSepForGlob(filePath)], globs).length > 0) {
90+
if (matcher(replacePathSepForGlob(filePath))) {
9091
files.add(file);
9192
}
9293
}

packages/jest-transform/src/shouldInstrument.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,28 @@
88
import * as path from 'path';
99
import type {Config} from '@jest/types';
1010
import {escapePathForRegex} from 'jest-regex-util';
11-
import {replacePathSepForGlob} from 'jest-util';
11+
import {globsToMatcher, replacePathSepForGlob} from 'jest-util';
1212
import micromatch = require('micromatch');
1313
import type {ShouldInstrumentOptions} from './types';
1414

1515
const MOCKS_PATTERN = new RegExp(
1616
escapePathForRegex(path.sep + '__mocks__' + path.sep),
1717
);
1818

19+
const cachedRegexes = new Map<string, RegExp>();
20+
const getRegex = (regexStr: string) => {
21+
if (!cachedRegexes.has(regexStr)) {
22+
cachedRegexes.set(regexStr, new RegExp(regexStr));
23+
}
24+
25+
const regex = cachedRegexes.get(regexStr)!;
26+
27+
// prevent stateful regexes from breaking, just in case
28+
regex.lastIndex = 0;
29+
30+
return regex;
31+
};
32+
1933
export default function shouldInstrument(
2034
filename: Config.Path,
2135
options: ShouldInstrumentOptions,
@@ -33,15 +47,15 @@ export default function shouldInstrument(
3347
}
3448

3549
if (
36-
!config.testPathIgnorePatterns.some(pattern => !!filename.match(pattern))
50+
!config.testPathIgnorePatterns.some(pattern =>
51+
getRegex(pattern).test(filename),
52+
)
3753
) {
3854
if (config.testRegex.some(regex => new RegExp(regex).test(filename))) {
3955
return false;
4056
}
4157

42-
if (
43-
micromatch([replacePathSepForGlob(filename)], config.testMatch).length
44-
) {
58+
if (globsToMatcher(config.testMatch)(replacePathSepForGlob(filename))) {
4559
return false;
4660
}
4761
}
@@ -59,10 +73,9 @@ export default function shouldInstrument(
5973
// still cover if `only` is specified
6074
!options.collectCoverageOnlyFrom &&
6175
options.collectCoverageFrom.length &&
62-
micromatch(
63-
[replacePathSepForGlob(path.relative(config.rootDir, filename))],
64-
options.collectCoverageFrom,
65-
).length === 0
76+
!globsToMatcher(options.collectCoverageFrom)(
77+
replacePathSepForGlob(path.relative(config.rootDir, filename)),
78+
)
6679
) {
6780
return false;
6881
}

packages/jest-util/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
"chalk": "^4.0.0",
1515
"graceful-fs": "^4.2.4",
1616
"is-ci": "^2.0.0",
17-
"make-dir": "^3.0.0"
17+
"make-dir": "^3.0.0",
18+
"micromatch": "^4.0.2"
1819
},
1920
"devDependencies": {
2021
"@types/graceful-fs": "^4.1.2",
2122
"@types/is-ci": "^2.0.0",
23+
"@types/micromatch": "^4.0.0",
2224
"@types/node": "*"
2325
},
2426
"engines": {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
8+
import micromatch = require('micromatch');
9+
import globsToMatcher from '../globsToMatcher';
10+
11+
it('works like micromatch with only positive globs', () => {
12+
const globs = ['**/*.test.js', '**/*.test.jsx'];
13+
const matcher = globsToMatcher(globs);
14+
15+
expect(matcher('some-module.js')).toBe(
16+
micromatch(['some-module.js'], globs).length > 0,
17+
);
18+
19+
expect(matcher('some-module.test.js')).toBe(
20+
micromatch(['some-module.test.js'], globs).length > 0,
21+
);
22+
});
23+
24+
it('works like micromatch with a mix of overlapping positive and negative globs', () => {
25+
const globs = ['**/*.js', '!**/*.test.js', '**/*.test.js'];
26+
const matcher = globsToMatcher(globs);
27+
28+
expect(matcher('some-module.js')).toBe(
29+
micromatch(['some-module.js'], globs).length > 0,
30+
);
31+
32+
expect(matcher('some-module.test.js')).toBe(
33+
micromatch(['some-module.test.js'], globs).length > 0,
34+
);
35+
36+
const globs2 = ['**/*.js', '!**/*.test.js', '**/*.test.js', '!**/*.test.js'];
37+
const matcher2 = globsToMatcher(globs2);
38+
39+
expect(matcher2('some-module.js')).toBe(
40+
micromatch(['some-module.js'], globs2).length > 0,
41+
);
42+
43+
expect(matcher2('some-module.test.js')).toBe(
44+
micromatch(['some-module.test.js'], globs2).length > 0,
45+
);
46+
});
47+
48+
it('works like micromatch with only negative globs', () => {
49+
const globs = ['!**/*.test.js', '!**/*.test.jsx'];
50+
const matcher = globsToMatcher(globs);
51+
52+
expect(matcher('some-module.js')).toBe(
53+
micromatch(['some-module.js'], globs).length > 0,
54+
);
55+
56+
expect(matcher('some-module.test.js')).toBe(
57+
micromatch(['some-module.test.js'], globs).length > 0,
58+
);
59+
});
60+
61+
it('works like micromatch with empty globs', () => {
62+
const globs = [];
63+
const matcher = globsToMatcher(globs);
64+
65+
expect(matcher('some-module.js')).toBe(
66+
micromatch(['some-module.js'], globs).length > 0,
67+
);
68+
69+
expect(matcher('some-module.test.js')).toBe(
70+
micromatch(['some-module.test.js'], globs).length > 0,
71+
);
72+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
8+
import micromatch = require('micromatch');
9+
import type {Config} from '@jest/types';
10+
import replacePathSepForGlob from './replacePathSepForGlob';
11+
12+
const globsToMatchersMap = new Map<
13+
string,
14+
{
15+
isMatch: (str: string) => boolean;
16+
negated: boolean;
17+
}
18+
>();
19+
20+
const micromatchOptions = {dot: true};
21+
22+
/**
23+
* Converts a list of globs into a function that matches a path against the
24+
* globs.
25+
*
26+
* Every time micromatch is called, it will parse the glob strings and turn
27+
* them into regexp instances. Instead of calling micromatch repeatedly with
28+
* the same globs, we can use this function which will build the micromatch
29+
* matchers ahead of time and then have an optimized path for determining
30+
* whether an individual path matches.
31+
*
32+
* This function is intended to match the behavior of `micromatch()`.
33+
*
34+
* @example
35+
* const isMatch = globsToMatcher(['*.js', '!*.test.js']);
36+
* isMatch('pizza.js'); // true
37+
* isMatch('pizza.test.js'); // false
38+
*/
39+
export default function globsToMatcher(
40+
globs: Array<Config.Glob>,
41+
): (path: Config.Path) => boolean {
42+
if (globs.length === 0) {
43+
// Since there were no globs given, we can simply have a fast path here and
44+
// return with a very simple function.
45+
return (_: Config.Path): boolean => false;
46+
}
47+
48+
const matchers = globs.map(glob => {
49+
if (!globsToMatchersMap.has(glob)) {
50+
// Matchers that are negated have different behavior than matchers that
51+
// are not negated, so we need to store this information ahead of time.
52+
const {negated} = micromatch.scan(glob, micromatchOptions);
53+
54+
const matcher = {
55+
isMatch: micromatch.matcher(glob, micromatchOptions),
56+
negated,
57+
};
58+
59+
globsToMatchersMap.set(glob, matcher);
60+
}
61+
62+
return globsToMatchersMap.get(glob)!;
63+
});
64+
65+
return (path: Config.Path): boolean => {
66+
const replacedPath = replacePathSepForGlob(path);
67+
let kept = undefined;
68+
let negatives = 0;
69+
70+
for (let i = 0; i < matchers.length; i++) {
71+
const {isMatch, negated} = matchers[i];
72+
73+
if (negated) {
74+
negatives++;
75+
}
76+
77+
const matched = isMatch(replacedPath);
78+
79+
if (!matched && negated) {
80+
// The path was not matched, and the matcher is a negated matcher, so we
81+
// want to omit the path. This means that the negative matcher is
82+
// filtering the path out.
83+
kept = false;
84+
} else if (matched && !negated) {
85+
// The path was matched, and the matcher is not a negated matcher, so we
86+
// want to keep the path.
87+
kept = true;
88+
}
89+
}
90+
91+
// If all of the globs were negative globs, then we want to include the path
92+
// as long as it was not explicitly not kept. Otherwise only include
93+
// the path if it was kept. This allows sets of globs that are all negated
94+
// to allow some paths to be matched, while sets of globs that are mixed
95+
// negated and non-negated to cause the negated matchers to only omit paths
96+
// and not keep them.
97+
return negatives === matchers.length ? kept !== false : !!kept;
98+
};
99+
}

packages/jest-util/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {default as convertDescriptorToString} from './convertDescriptorToString'
1818
import * as specialChars from './specialChars';
1919
export {default as replacePathSepForGlob} from './replacePathSepForGlob';
2020
export {default as testPathPatternToRegExp} from './testPathPatternToRegExp';
21+
export {default as globsToMatcher} from './globsToMatcher';
2122
import * as preRunMessage from './preRunMessage';
2223
export {default as pluralize} from './pluralize';
2324
export {default as formatTime} from './formatTime';

0 commit comments

Comments
 (0)