Skip to content

Commit 2d6e67f

Browse files
[CI] Replace Jest resolver with fast glob in filterEmptyJestConfigs (elastic#265188)
## Summary Replace Jest's full config resolver (`readConfig` + `Runtime.createContext` + `SearchSource.getTestPaths`) with a simple globby check in `filterEmptyJestConfigs`. The old approach ran on each of 1,122 `jest.config.js` files and took ~100s on CI. The glob check produces identical results in ~6s. ### Changes - `get_tests_from_config.ts`: New `hasTestFiles()` function using globby instead of Jest internals. `filterEmptyJestConfigs` is now synchronous. - `pick_test_group_run_order.ts`: Updated call site (no longer async, removed `os.availableParallelism()`). - `get_tests_from_config.test.ts`: Validation test that scans every `jest.config.js` in the repo and verifies its `testMatch`/`testRegex` patterns are covered by the glob. Catches drift if a config is added with an unusual file extension. Saves **~94s** per build. Split from elastic#264875. Made with [Cursor](https://cursor.com) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent b26dfb3 commit 2d6e67f

3 files changed

Lines changed: 142 additions & 48 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import path from 'path';
11+
import * as globby from 'globby';
12+
13+
const REPO_ROOT = path.resolve(__dirname, '../../..');
14+
15+
/**
16+
* Extensions covered by the glob in filterEmptyJestConfigs.
17+
* If a jest config uses a testMatch/testRegex that doesn't end in one of these,
18+
* the fast glob could miss test files and silently skip the config in CI.
19+
*/
20+
const COVERED_EXTENSIONS = /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/;
21+
22+
describe('filterEmptyJestConfigs glob coverage', () => {
23+
const allConfigs = globby.sync(['**/jest.config.js', '!**/__fixtures__/**'], {
24+
cwd: REPO_ROOT,
25+
absolute: true,
26+
ignore: ['**/node_modules/**'],
27+
});
28+
29+
it('found jest configs to validate', () => {
30+
expect(allConfigs.length).toBeGreaterThan(100);
31+
});
32+
33+
it('every jest config testMatch/testRegex is covered by the glob patterns', () => {
34+
const uncovered: string[] = [];
35+
36+
for (const configPath of allConfigs) {
37+
let config: Record<string, unknown>;
38+
try {
39+
config = require(configPath);
40+
} catch {
41+
continue;
42+
}
43+
44+
const testMatch = config.testMatch as string[] | undefined;
45+
const testRegex = config.testRegex as string | string[] | undefined;
46+
47+
if (testMatch) {
48+
for (const pattern of testMatch) {
49+
// Extract the file extension portion from the glob pattern
50+
const extMatch = pattern.match(/\*\.([\w|{},]+)$/);
51+
if (extMatch) {
52+
const extensions = extMatch[1].replace(/[{}]/g, '').split(',');
53+
for (const ext of extensions) {
54+
const testFilename = `example.test.${ext}`;
55+
if (!COVERED_EXTENSIONS.test(testFilename)) {
56+
const specFilename = `example.spec.${ext}`;
57+
if (!COVERED_EXTENSIONS.test(specFilename)) {
58+
uncovered.push(
59+
`${path.relative(
60+
REPO_ROOT,
61+
configPath
62+
)}: testMatch extension ".${ext}" not covered`
63+
);
64+
}
65+
}
66+
}
67+
}
68+
}
69+
}
70+
71+
if (testRegex) {
72+
const regexes = Array.isArray(testRegex) ? testRegex : [testRegex];
73+
for (const regex of regexes) {
74+
// Verify the regex would match files ending in .test.ts or .spec.ts etc.
75+
const sampleFiles = [
76+
'foo.test.ts',
77+
'foo.test.tsx',
78+
'foo.test.js',
79+
'foo.test.jsx',
80+
'foo.test.mjs',
81+
'foo.spec.ts',
82+
];
83+
const re = new RegExp(regex);
84+
const anyMatch = sampleFiles.some((f) => re.test(f));
85+
if (!anyMatch) {
86+
uncovered.push(
87+
`${path.relative(
88+
REPO_ROOT,
89+
configPath
90+
)}: testRegex "${regex}" doesn't match standard test file names`
91+
);
92+
}
93+
}
94+
}
95+
}
96+
97+
if (uncovered.length > 0) {
98+
fail(
99+
`The following jest configs use patterns not covered by filterEmptyJestConfigs glob.\n` +
100+
`Update TEST_FILE_PATTERNS in get_tests_from_config.ts to cover them:\n\n` +
101+
uncovered.map((u) => ` - ${u}`).join('\n')
102+
);
103+
}
104+
});
105+
});

.buildkite/pipeline-utils/ci-stats/get_tests_from_config.ts

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,46 @@
66
* your election, the "Elastic License 2.0", the "GNU Affero General Public
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
9-
import { readConfig } from 'jest-config';
10-
import { SearchSource } from 'jest';
11-
import Runtime from 'jest-runtime';
12-
import { resolve } from 'path';
13-
import { getKibanaDir, runBatchedPromises } from '#pipeline-utils';
9+
import { dirname, resolve } from 'path';
10+
import * as globby from 'globby';
11+
import { getKibanaDir } from '#pipeline-utils';
1412

15-
export async function getTestsFromJestConfig(configPath: string): Promise<string[]> {
16-
try {
17-
const emptyArgv = {
18-
$0: '',
19-
_: [],
20-
};
21-
const config = await readConfig(emptyArgv, configPath);
22-
const searchSource = new SearchSource(
23-
await Runtime.createContext(config.projectConfig, {
24-
maxWorkers: 1,
25-
watchman: false,
26-
watch: false,
27-
console: {
28-
...console,
29-
warn() {
30-
// ignore haste-map warnings
31-
},
32-
},
33-
})
34-
);
13+
const TEST_FILE_PATTERNS = ['**/*.test.{ts,tsx,js,jsx,mjs}', '**/*.spec.{ts,tsx,js,jsx,mjs}'];
3514

36-
const results = await searchSource.getTestPaths(config.globalConfig, config.projectConfig);
37-
return results.tests.map((t) => t.path);
38-
} catch (error) {
39-
console.error(
40-
`Error while resolving test files from config: ${configPath} - validate your config.`
41-
);
42-
throw error;
15+
// Loaded lazily because getKibanaDir() isn't available at module-init time.
16+
let ignorePatterns: string[];
17+
function getIgnorePatterns(): string[] {
18+
if (!ignorePatterns) {
19+
// Integration test patterns loaded from the Jest integration preset so this
20+
// stays in sync automatically if that preset ever changes.
21+
// eslint-disable-next-line @typescript-eslint/no-var-requires
22+
const integrationPreset = require(resolve(
23+
getKibanaDir(),
24+
'src/platform/packages/shared/kbn-test/jest_integration_node/jest-preset.js'
25+
));
26+
ignorePatterns = ['**/node_modules/**', ...integrationPreset.testMatch];
4327
}
28+
return ignorePatterns;
4429
}
4530

46-
export async function filterEmptyJestConfigs(
47-
jestUnitConfigsWithEmpties: string[],
48-
maxParallelism = 1
49-
): Promise<string[]> {
50-
const promiseThunks = jestUnitConfigsWithEmpties.map((configPath) => async () => {
51-
const kibanaRelativePath = resolve(getKibanaDir(), configPath);
52-
const testFiles = await getTestsFromJestConfig(kibanaRelativePath);
53-
return testFiles?.length > 0 ? [configPath] : [];
31+
/**
32+
* Fast check for whether a jest config's directory contains any test files.
33+
* Uses a simple glob instead of Jest's full resolver (readConfig + Runtime.createContext
34+
* + SearchSource.getTestPaths) which is ~20x slower across 1000+ configs.
35+
*/
36+
function hasTestFiles(configAbsPath: string): boolean {
37+
const dir = dirname(configAbsPath);
38+
const matches = globby.sync(TEST_FILE_PATTERNS, {
39+
cwd: dir,
40+
ignore: getIgnorePatterns(),
41+
onlyFiles: true,
5442
});
55-
const nonEmptyConfigPaths = await runBatchedPromises(promiseThunks, maxParallelism);
56-
// flat-mapping works better type-wise than filtering an Array<string | null>
57-
return nonEmptyConfigPaths.flat();
43+
return matches.length > 0;
44+
}
45+
46+
export function filterEmptyJestConfigs(jestUnitConfigsWithEmpties: string[]): string[] {
47+
const kibanaDir = getKibanaDir();
48+
return jestUnitConfigsWithEmpties.filter((configPath) =>
49+
hasTestFiles(resolve(kibanaDir, configPath))
50+
);
5851
}

.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
*/
99

1010
import * as Fs from 'fs';
11-
import os from 'os';
1211

1312
import * as globby from 'globby';
1413
import minimatch from 'minimatch';
@@ -219,10 +218,7 @@ export async function pickTestGroupRunOrder() {
219218
ignore: [...DISABLED_JEST_CONFIGS, '**/node_modules/**'],
220219
})
221220
: [];
222-
const jestUnitConfigsFiltered = await filterEmptyJestConfigs(
223-
jestUnitConfigsWithEmpties,
224-
os.availableParallelism()
225-
);
221+
const jestUnitConfigsFiltered = filterEmptyJestConfigs(jestUnitConfigsWithEmpties);
226222
// Expand sharded unit configs (e.g. cases/jest.config.js) into shard-annotated entries
227223
let jestUnitConfigs = expandShardedJestConfigs(jestUnitConfigsFiltered);
228224

0 commit comments

Comments
 (0)