[jest] refactor config check (#135960)

This commit is contained in:
Spencer 2022-07-08 08:54:38 -05:00 committed by GitHub
parent 7731c414db
commit 1134d35e03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 202 additions and 527 deletions

View file

@ -532,6 +532,7 @@
"@istanbuljs/schema": "^0.1.2",
"@jest/console": "^26.6.2",
"@jest/reporters": "^26.6.2",
"@jest/types": "^26",
"@kbn/ambient-storybook-types": "link:bazel-bin/packages/kbn-ambient-storybook-types",
"@kbn/ambient-ui-types": "link:bazel-bin/packages/kbn-ambient-ui-types",
"@kbn/axe-config": "link:bazel-bin/packages/kbn-axe-config",
@ -1026,10 +1027,12 @@
"jest-canvas-mock": "^2.3.1",
"jest-circus": "^26.6.3",
"jest-cli": "^26.6.3",
"jest-config": "^26",
"jest-diff": "^26.6.2",
"jest-environment-jsdom": "^26.6.2",
"jest-environment-jsdom-thirteen": "^1.0.1",
"jest-raw-loader": "^1.0.1",
"jest-runtime": "^26",
"jest-silent-reporter": "^0.5.0",
"jest-snapshot": "^26.6.2",
"jest-specific-snapshot": "2.0.0",

View file

@ -1,84 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import mockFs from 'mock-fs';
import { GroupedTestFiles } from './group_test_files';
import {
findMissingConfigFiles,
INTEGRATION_CONFIG_NAME,
UNIT_CONFIG_NAME,
} from './find_missing_config_files';
beforeEach(async () => {
mockFs({
'/packages': {
a: {
[UNIT_CONFIG_NAME]: '{}',
},
},
'/src': {
c: {
[UNIT_CONFIG_NAME]: '{}',
},
d: {
[INTEGRATION_CONFIG_NAME]: '{}',
},
},
});
});
afterEach(mockFs.restore);
it('returns a list of config files which are not found on disk, or are not files', async () => {
const groups: GroupedTestFiles = new Map([
[
{
type: 'pkg',
path: '/packages/a',
},
{
unit: ['/packages/a/test.js'],
},
],
[
{
type: 'pkg',
path: '/packages/b',
},
{
integration: ['/packages/b/integration_tests/test.js'],
},
],
[
{
type: 'src',
path: '/src/c',
},
{
unit: ['/src/c/test.js'],
integration: ['/src/c/integration_tests/test.js'],
},
],
[
{
type: 'src',
path: '/src/d',
},
{
unit: ['/src/d/test.js'],
},
],
]);
await expect(findMissingConfigFiles(groups)).resolves.toEqual([
'/packages/b/jest.integration.config.js',
'/src/c/jest.integration.config.js',
'/src/d/jest.config.js',
]);
});

View file

@ -1,49 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Fsp from 'fs/promises';
import Path from 'path';
import { asyncMapWithLimit } from '@kbn/std';
import { GroupedTestFiles } from './group_test_files';
export const UNIT_CONFIG_NAME = 'jest.config.js';
export const INTEGRATION_CONFIG_NAME = 'jest.integration.config.js';
async function isFile(path: string) {
try {
const stats = await Fsp.stat(path);
return stats.isFile();
} catch (error) {
if (error.code === 'ENOENT') {
return false;
}
throw error;
}
}
export async function findMissingConfigFiles(groups: GroupedTestFiles) {
const expectedConfigs = [...groups].flatMap(([owner, tests]) => {
const configs: string[] = [];
if (tests.unit?.length) {
configs.push(Path.resolve(owner.path, UNIT_CONFIG_NAME));
}
if (tests.integration?.length) {
configs.push(Path.resolve(owner.path, INTEGRATION_CONFIG_NAME));
}
return configs;
});
return (
await asyncMapWithLimit(expectedConfigs, 20, async (path) =>
!(await isFile(path)) ? [path] : []
)
).flat();
}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Fs from 'fs';
import Path from 'path';
import execa from 'execa';
import minimatch from 'minimatch';
import { REPO_ROOT } from '@kbn/utils';
// @ts-expect-error jest-preset is necessarily a JS file
import { testMatch } from '../../../jest-preset';
const UNIT_CONFIG_NAME = 'jest.config.js';
const INTEGRATION_CONFIG_NAME = 'jest.integration.config.js';
export async function getAllJestPaths() {
const proc = await execa('git', ['ls-files', '-comt', '--exclude-standard'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
buffer: true,
});
const testsRe = (testMatch as string[]).map((p) => minimatch.makeRe(p));
const classify = (rel: string) => {
if (testsRe.some((re) => re.test(rel))) {
return 'test' as const;
}
const basename = Path.basename(rel);
return basename === UNIT_CONFIG_NAME || basename === INTEGRATION_CONFIG_NAME
? ('config' as const)
: undefined;
};
const tests = new Set<string>();
const configs = new Set<string>();
for (const line of proc.stdout.split('\n').map((l) => l.trim())) {
if (!line) {
continue;
}
const rel = line.slice(2); // trim the single char status from the line
const type = classify(rel);
if (!type) {
continue;
}
const set = type === 'test' ? tests : configs;
const abs = Path.resolve(REPO_ROOT, rel);
if (line.startsWith('C ')) {
// this line indicates that the previous path is changed in the working tree, so we need to determine if
// it was deleted, and if so, remove it from the set we added it to
if (!Fs.existsSync(abs)) {
set.delete(abs);
}
} else {
set.add(abs);
}
}
return {
tests,
configs,
};
}

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import execa from 'execa';
import minimatch from 'minimatch';
import { REPO_ROOT } from '@kbn/utils';
// @ts-expect-error jest-preset is necessarily a JS file
import { testMatch } from '../../../jest-preset';
export async function getAllTestFiles() {
const proc = await execa('git', ['ls-files', '-co', '--exclude-standard'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
buffer: true,
});
const patterns: RegExp[] = testMatch.map((p: string) => minimatch.makeRe(p));
return proc.stdout
.split('\n')
.flatMap((l) => l.trim() || [])
.filter((l) => patterns.some((p) => p.test(l)))
.map((p) => Path.resolve(REPO_ROOT, p));
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readConfig } from 'jest-config';
import { createContext } from 'jest-runtime';
import { SearchSource } from 'jest';
import { asyncMapWithLimit } from '@kbn/std';
const EMPTY_ARGV = {
$0: '',
_: [],
};
const NO_WARNINGS_CONSOLE = {
...console,
warn() {
// ignore haste-map warnings
},
};
export interface TestsForConfigPath {
path: string;
testPaths: Set<string>;
}
export async function getTestsForConfigPaths(
configPaths: Iterable<string>
): Promise<TestsForConfigPath[]> {
return await asyncMapWithLimit(configPaths, 60, async (path) => {
const config = await readConfig(EMPTY_ARGV, path);
const searchSource = new SearchSource(
await createContext(config.projectConfig, {
maxWorkers: 1,
watchman: false,
watch: false,
console: NO_WARNINGS_CONSOLE,
})
);
const results = await searchSource.getTestPaths(config.globalConfig, undefined, undefined);
return {
path,
testPaths: new Set(results.tests.map((t) => t.path)),
};
});
}

View file

@ -1,117 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { groupTestFiles } from './group_test_files';
it('properly assigns tests to src roots and packages based on location', () => {
const grouped = groupTestFiles(
[
'/packages/pkg1/test.js',
'/packages/pkg1/integration_tests/test.js',
'/packages/pkg2/integration_tests/test.js',
'/packages/group/pkg3/test.js',
'/packages/group/subgroup/pkg4/test.js',
'/packages/group/subgroup/pkg4/integration_tests/test.js',
'/src/a/integration_tests/test.js',
'/src/b/test.js',
'/tests/b/test.js',
'/src/group/c/test.js',
'/src/group/c/integration_tests/test.js',
'/src/group/subgroup/d/test.js',
'/src/group/subgroup/d/integration_tests/test.js',
],
['/src/group/subgroup', '/src/group', '/src'],
['/packages/pkg1', '/packages/pkg2', '/packages/group/pkg3', '/packages/group/subgroup/pkg4']
);
expect(grouped).toMatchInlineSnapshot(`
Object {
"grouped": Map {
Object {
"path": "/packages/pkg1",
"type": "pkg",
} => Object {
"integration": Array [
"/packages/pkg1/integration_tests/test.js",
],
"unit": Array [
"/packages/pkg1/test.js",
],
},
Object {
"path": "/packages/pkg2",
"type": "pkg",
} => Object {
"integration": Array [
"/packages/pkg2/integration_tests/test.js",
],
},
Object {
"path": "/packages/group/pkg3",
"type": "pkg",
} => Object {
"unit": Array [
"/packages/group/pkg3/test.js",
],
},
Object {
"path": "/packages/group/subgroup/pkg4",
"type": "pkg",
} => Object {
"integration": Array [
"/packages/group/subgroup/pkg4/integration_tests/test.js",
],
"unit": Array [
"/packages/group/subgroup/pkg4/test.js",
],
},
Object {
"path": "/src/a",
"type": "src",
} => Object {
"integration": Array [
"/src/a/integration_tests/test.js",
],
},
Object {
"path": "/src/b",
"type": "src",
} => Object {
"unit": Array [
"/src/b/test.js",
],
},
Object {
"path": "/src/group/c",
"type": "src",
} => Object {
"integration": Array [
"/src/group/c/integration_tests/test.js",
],
"unit": Array [
"/src/group/c/test.js",
],
},
Object {
"path": "/src/group/subgroup/d",
"type": "src",
} => Object {
"integration": Array [
"/src/group/subgroup/d/integration_tests/test.js",
],
"unit": Array [
"/src/group/subgroup/d/test.js",
],
},
},
"invalid": Array [
"/tests/b/test.js",
],
}
`);
});

View file

@ -1,95 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import isPathInside from 'is-path-inside';
export interface Owner {
type: 'pkg' | 'src';
path: string;
}
export interface TestGroups {
unit?: string[];
integration?: string[];
}
export type GroupedTestFiles = Map<Owner, TestGroups>;
/**
* Consumes the list of test files discovered along with the srcRoots and packageDirs to assign
* each test file to a specific "owner", either a package or src directory, were we will eventually
* expect to find relevant config files
*/
export function groupTestFiles(
testFiles: string[],
srcRoots: string[],
packageDirs: string[]
): { grouped: GroupedTestFiles; invalid: string[] } {
const invalid: string[] = [];
const testsByOwner = new Map<string, TestGroups>();
for (const testFile of testFiles) {
const type = testFile.includes('integration_tests') ? 'integration' : 'unit';
let ownerKey;
// try to match the test file to a package first
for (const pkgDir of packageDirs) {
if (isPathInside(testFile, pkgDir)) {
ownerKey = `pkg:${pkgDir}`;
break;
}
}
// try to match the test file to a src root
if (!ownerKey) {
for (const srcRoot of srcRoots) {
if (isPathInside(testFile, srcRoot)) {
const segments = Path.relative(srcRoot, testFile).split(Path.sep);
if (segments.length > 1) {
ownerKey = `src:${Path.join(srcRoot, segments[0])}`;
break;
}
// if there are <= 1 relative segments then this file is directly in the "root"
// which isn't supported, roots are directories which have test dirs in them.
// We should ignore this match and match a higher-level root if possible
continue;
}
}
}
if (!ownerKey) {
invalid.push(testFile);
continue;
}
const tests = testsByOwner.get(ownerKey);
if (!tests) {
testsByOwner.set(ownerKey, { [type]: [testFile] });
} else {
const byType = tests[type];
if (!byType) {
tests[type] = [testFile];
} else {
byType.push(testFile);
}
}
}
return {
invalid,
grouped: new Map<Owner, TestGroups>(
[...testsByOwner.entries()].map(([key, tests]) => {
const [type, ...path] = key.split(':');
const owner: Owner = {
type: type as Owner['type'],
path: path.join(':'),
};
return [owner, tests];
})
),
};
}

View file

@ -6,10 +6,5 @@
* Side Public License, v 1.
*/
export { getAllTestFiles } from './get_all_test_files';
export { groupTestFiles } from './group_test_files';
export {
findMissingConfigFiles,
UNIT_CONFIG_NAME,
INTEGRATION_CONFIG_NAME,
} from './find_missing_config_files';
export * from './get_all_jest_paths';
export * from './get_tests_for_config_paths';

View file

@ -6,111 +6,92 @@
* Side Public License, v 1.
*/
import Fsp from 'fs/promises';
import Path from 'path';
import Mustache from 'mustache';
import { run } from '@kbn/dev-cli-runner';
import { createFailError } from '@kbn/dev-cli-errors';
import { REPO_ROOT } from '@kbn/utils';
import { discoverBazelPackageLocations } from '@kbn/bazel-packages';
import {
getAllTestFiles,
groupTestFiles,
findMissingConfigFiles,
UNIT_CONFIG_NAME,
} from './configs';
import { getAllJestPaths, getTestsForConfigPaths } from './configs';
const unitTestingTemplate: string = `module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '{{{relToRoot}}}',
roots: ['<rootDir>/{{{modulePath}}}'],
const fmtMs = (ms: number) => {
if (ms < 1000) {
return `${Math.round(ms)} ms`;
}
return `${(Math.round(ms) / 1000).toFixed(2)} s`;
};
`;
const integrationTestingTemplate: string = `module.exports = {
preset: '@kbn/test/jest_integration_node',
rootDir: '{{{relToRoot}}}',
roots: ['<rootDir>/{{{modulePath}}}'],
};
`;
const roots: string[] = [
'x-pack/plugins/security_solution/public',
'x-pack/plugins/security_solution/server',
'x-pack/plugins/security_solution',
'x-pack/plugins',
'src/plugins',
'test',
'src/core',
'src',
].map((rel) => Path.resolve(REPO_ROOT, rel));
const fmtList = (list: Iterable<string>) => [...list].map((i) => ` - ${i}`).join('\n');
export async function runCheckJestConfigsCli() {
run(
async ({ flags: { fix = false }, log }) => {
const packageDirs = [
...discoverBazelPackageLocations(REPO_ROOT),
// kbn-pm is a weird package currently and needs to be added explicitly
Path.resolve(REPO_ROOT, 'packages/kbn-pm'),
];
async ({ log }) => {
const start = performance.now();
const testFiles = await getAllTestFiles();
const { grouped, invalid } = groupTestFiles(testFiles, roots, packageDirs);
const jestPaths = await getAllJestPaths();
const allConfigs = await getTestsForConfigPaths(jestPaths.configs);
const missingConfigs = new Set<string>();
const multipleConfigs = new Set<{ configs: string[]; rel: string }>();
if (invalid.length) {
const paths = invalid.map((path) => Path.relative(REPO_ROOT, path)).join('\n - ');
log.error(
`The following test files exist outside packages or pre-defined roots:\n - ${paths}`
);
throw createFailError(
`Move the above files a pre-defined test root, a package, or configure an additional root to handle this file.`
);
}
for (const testPath of jestPaths.tests) {
const configs = allConfigs
.filter((c) => c.testPaths.has(testPath))
.map((c) => Path.relative(REPO_ROOT, c.path))
.sort((a, b) => Path.dirname(a).localeCompare(Path.dirname(b)));
const missing = await findMissingConfigFiles(grouped);
if (missing.length) {
log.error(
`The following Jest config files do not exist for which there are test files for:\n${[
...missing,
]
.map((file) => ` - ${file}`)
.join('\n')}`
);
if (fix) {
for (const file of missing) {
const template =
Path.basename(file) === UNIT_CONFIG_NAME
? unitTestingTemplate
: integrationTestingTemplate;
const modulePath = Path.dirname(file);
const content = Mustache.render(template, {
relToRoot: Path.relative(modulePath, REPO_ROOT),
modulePath,
});
await Fsp.writeFile(file, content);
log.info('created %s', file);
}
} else {
throw createFailError(
`Run 'node scripts/check_jest_configs --fix' to create the missing config files`
);
if (configs.length === 0) {
missingConfigs.add(Path.relative(REPO_ROOT, testPath));
} else if (configs.length > 1) {
multipleConfigs.add({
configs,
rel: Path.relative(REPO_ROOT, testPath),
});
}
}
if (missingConfigs.size) {
log.error(
`The following test files are not selected by any jest config file:\n${fmtList(
missingConfigs
)}`
);
}
if (multipleConfigs.size) {
const overlaps = new Map<string, { configs: string[]; rels: string[] }>();
for (const { configs, rel } of multipleConfigs) {
const key = configs.join(':');
const group = overlaps.get(key);
if (group) {
group.rels.push(rel);
} else {
overlaps.set(key, {
configs,
rels: [rel],
});
}
}
const list = [...overlaps.values()]
.map(
({ configs, rels }) =>
`configs: ${configs
.map((c) => Path.relative(REPO_ROOT, c))
.join(', ')}\ntests:\n${fmtList(rels)}`
)
.join('\n\n');
log.error(`The following test files are selected by multiple config files:\n${list}`);
}
if (missingConfigs.size || multipleConfigs.size) {
throw createFailError('Please resolve the previously logged issues.');
}
log.success('Checked all jest config files in', fmtMs(performance.now() - start));
},
{
description: 'Check that all test files are covered by a Jest config',
flags: {
boolean: ['fix'],
help: `
--fix Attempt to create missing config files
`,
},
description: 'Check that all test files are covered by one, and only one, Jest config',
}
);
}

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/src/core'],
testRunner: 'jasmine2',
};

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/chart_expressions'],
};

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/vis_types'],
};

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/test/analytics'],
};

View file

@ -2563,7 +2563,7 @@
"@types/istanbul-reports" "^1.1.1"
"@types/yargs" "^13.0.0"
"@jest/types@^26.6.2":
"@jest/types@^26", "@jest/types@^26.6.2":
version "26.6.2"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e"
integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==
@ -18386,7 +18386,7 @@ jest-cli@^26.6.3:
prompts "^2.0.1"
yargs "^15.4.1"
jest-config@^26.6.3:
jest-config@^26, jest-config@^26.6.3:
version "26.6.3"
resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349"
integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==
@ -18799,7 +18799,7 @@ jest-runner@^26.6.3:
source-map-support "^0.5.6"
throat "^5.0.0"
jest-runtime@^26.6.3:
jest-runtime@^26, jest-runtime@^26.6.3:
version "26.6.3"
resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b"
integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==