refactor jest config validation logic (#134496)

This commit is contained in:
Spencer 2022-06-15 13:42:55 -07:00 committed by GitHub
parent 07e4ca46ef
commit 1656fe9304
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 450 additions and 234 deletions

View file

@ -65,6 +65,7 @@ RUNTIME_DEPS = [
"@npm//jest-styled-components",
"@npm//joi",
"@npm//js-yaml",
"@npm//minimatch",
"@npm//mustache",
"@npm//normalize-path",
"@npm//prettier",
@ -113,6 +114,7 @@ TYPES_DEPS = [
"@npm//@types/js-yaml",
"@npm//@types/joi",
"@npm//@types/lodash",
"@npm//@types/minimatch",
"@npm//@types/mustache",
"@npm//@types/normalize-path",
"@npm//@types/node",

View file

@ -7,16 +7,27 @@
*/
import execa from 'execa';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import Path from 'path';
import { REPO_ROOT } from '@kbn/utils';
import { run } from '@kbn/dev-cli-runner';
import { createFailError } from '@kbn/dev-cli-errors';
import { FTR_CONFIGS_MANIFEST_PATHS } from './ftr_configs_manifest';
const THIS_PATH = Path.resolve(
REPO_ROOT,
'packages/kbn-test/src/functional_test_runner/lib/config/run_check_ftr_configs_cli.ts'
);
const THIS_REL = Path.relative(REPO_ROOT, THIS_PATH);
const IGNORED_PATHS = [
THIS_PATH,
Path.resolve(REPO_ROOT, 'packages/kbn-test/src/jest/run_check_jest_configs_cli.ts'),
];
export async function runCheckFtrConfigsCli() {
run(
async () => {
async ({ log }) => {
const { stdout } = await execa('git', [
'ls-tree',
'--full-tree',
@ -28,10 +39,10 @@ export async function runCheckFtrConfigsCli() {
const files = stdout
.trim()
.split('\n')
.map((file) => resolve(REPO_ROOT, file));
.map((file) => Path.resolve(REPO_ROOT, file));
const possibleConfigs = files.filter((file) => {
if (file.includes('run_check_ftr_configs_cli.ts')) {
if (IGNORED_PATHS.includes(file)) {
return false;
}
@ -56,12 +67,15 @@ export async function runCheckFtrConfigsCli() {
.match(/(testRunner)|(testFiles)/);
});
for (const config of possibleConfigs) {
if (!FTR_CONFIGS_MANIFEST_PATHS.includes(config)) {
throw createFailError(
`${config} looks like a new FTR config. Please add it to .buildkite/ftr_configs.yml. If it's not an FTR config, please contact #kibana-operations`
);
}
const invalid = possibleConfigs.filter((path) => !FTR_CONFIGS_MANIFEST_PATHS.includes(path));
if (invalid.length) {
const invalidList = invalid.map((path) => Path.relative(REPO_ROOT, path)).join('\n - ');
log.error(
`The following files look like FTR configs which are not listed in .buildkite/ftr_configs.yml:\n - ${invalidList}`
);
throw createFailError(
`Please add the listed paths to .buildkite/ftr_configs.yml. If it's not an FTR config, you can add it to the IGNORED_PATHS in ${THIS_REL} or contact #kibana-operations`
);
}
},
{

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`jestConfigs #expected throws if test file outside root 1`] = `[Error: Test file (bad.test.js) can not exist outside roots (packages/b/nested, packages). Move it to a root or configure additional root.]`;

View file

@ -0,0 +1,84 @@
/*
* 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

@ -0,0 +1,49 @@
/*
* 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,32 @@
/*
* 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,117 @@
/*
* 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

@ -0,0 +1,95 @@
/*
* 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,4 +6,10 @@
* Side Public License, v 1.
*/
export * from './jest_configs';
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';

View file

@ -1,116 +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 fs from 'fs';
import { JestConfigs } from './jest_configs';
describe('jestConfigs', () => {
let jestConfigs: JestConfigs;
beforeEach(async () => {
mockFs({
'/kbn-test/packages': {
a: {
'jest.config.js': '',
'a_first.test.js': '',
'a_second.test.js': '',
},
b: {
'b.test.js': '',
integration_tests: {
'b_integration.test.js': '',
},
nested: {
d: {
'd.test.js': '',
},
},
},
c: {
'jest.integration.config.js': '',
integration_tests: {
'c_integration.test.js': '',
},
},
},
});
jestConfigs = new JestConfigs('/kbn-test', ['packages/b/nested', 'packages']);
});
afterEach(mockFs.restore);
describe('#files', () => {
it('lists unit test files', async () => {
const files = await jestConfigs.files('unit');
expect(files).toEqual([
'packages/a/a_first.test.js',
'packages/a/a_second.test.js',
'packages/b/b.test.js',
'packages/b/nested/d/d.test.js',
]);
});
it('lists integration test files', async () => {
const files = await jestConfigs.files('integration');
expect(files).toEqual([
'packages/b/integration_tests/b_integration.test.js',
'packages/c/integration_tests/c_integration.test.js',
]);
});
});
describe('#expected', () => {
it('expects unit config files', async () => {
const files = await jestConfigs.expected('unit');
expect(files).toEqual([
'packages/a/jest.config.js',
'packages/b/jest.config.js',
'packages/b/nested/d/jest.config.js',
]);
});
it('expects integration config files', async () => {
const files = await jestConfigs.expected('integration');
expect(files).toEqual([
'packages/b/jest.integration.config.js',
'packages/c/jest.integration.config.js',
]);
});
it('throws if test file outside root', async () => {
fs.writeFileSync('/kbn-test/bad.test.js', '');
await expect(() => jestConfigs.expected('unit')).rejects.toMatchSnapshot();
});
});
describe('#existing', () => {
it('lists existing unit test config files', async () => {
const files = await jestConfigs.existing('unit');
expect(files).toEqual(['packages/a/jest.config.js']);
});
it('lists existing integration test config files', async () => {
const files = await jestConfigs.existing('integration');
expect(files).toEqual(['packages/c/jest.integration.config.js']);
});
});
describe('#missing', () => {
it('lists existing unit test config files', async () => {
const files = await jestConfigs.missing('unit');
expect(files).toEqual(['packages/b/jest.config.js', 'packages/b/nested/d/jest.config.js']);
});
it('lists existing integration test config files', async () => {
const files = await jestConfigs.missing('integration');
expect(files).toEqual(['packages/b/jest.integration.config.js']);
});
});
});

View file

@ -1,86 +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 globby from 'globby';
// @ts-ignore
import { testMatch } from '../../../jest-preset';
export const CONFIG_NAMES = {
unit: 'jest.config.js',
integration: 'jest.integration.config.js',
};
export class JestConfigs {
cwd: string;
roots: string[];
allFiles: string[] | undefined;
constructor(cwd: string, roots: string[]) {
this.cwd = cwd;
// sort roots by length so when we use `file.startsWith()` we will find the most specific root first
this.roots = roots.slice().sort((a, b) => b.length - a.length);
}
async files(type: 'unit' | 'integration') {
if (!this.allFiles) {
this.allFiles = await globby(testMatch, {
gitignore: true,
cwd: this.cwd,
});
}
return this.allFiles.filter((f) =>
type === 'integration' ? f.includes('integration_tests') : !f.includes('integration_tests')
);
}
async expected(type: 'unit' | 'integration') {
const filesForType = await this.files(type);
const directories: Set<string> = new Set();
filesForType.forEach((file) => {
const root = this.roots.find((r) => file.startsWith(r));
if (root) {
const splitPath = file.substring(root.length).split(path.sep);
if (splitPath.length > 2) {
const name = splitPath[1];
directories.add([root, name].join(path.sep));
}
} else {
throw new Error(
`Test file (${file}) can not exist outside roots (${this.roots.join(
', '
)}). Move it to a root or configure additional root.`
);
}
});
return [...directories].map((d) => [d, CONFIG_NAMES[type]].join(path.sep));
}
async existing(type: 'unit' | 'integration') {
return await globby(`**/${CONFIG_NAMES[type]}`, {
gitignore: true,
cwd: this.cwd,
});
}
async missing(type: 'unit' | 'integration') {
const expectedConfigs = await this.expected(type);
const existingConfigs = await this.existing(type);
return await expectedConfigs.filter((x) => !existingConfigs.includes(x));
}
async allMissing() {
return (await this.missing('unit')).concat(await this.missing('integration'));
}
}

View file

@ -6,16 +6,21 @@
* Side Public License, v 1.
*/
import { writeFileSync } from 'fs';
import path from 'path';
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 { getAllRepoRelativeBazelPackageDirs } from '@kbn/bazel-packages';
import { discoverBazelPackageLocations } from '@kbn/bazel-packages';
import { JestConfigs, CONFIG_NAMES } from './configs';
import {
getAllTestFiles,
groupTestFiles,
findMissingConfigFiles,
UNIT_CONFIG_NAME,
} from './configs';
const unitTestingTemplate: string = `module.exports = {
preset: '@kbn/test/jest_node',
@ -40,15 +45,31 @@ const roots: string[] = [
'test',
'src/core',
'src',
...getAllRepoRelativeBazelPackageDirs(),
];
].map((rel) => Path.resolve(REPO_ROOT, rel));
export async function runCheckJestConfigsCli() {
run(
async ({ flags: { fix = false }, log }) => {
const jestConfigs = new JestConfigs(REPO_ROOT, roots);
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'),
];
const missing = await jestConfigs.allMissing();
const testFiles = await getAllTestFiles();
const { grouped, invalid } = groupTestFiles(testFiles, roots, packageDirs);
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.`
);
}
const missing = await findMissingConfigFiles(grouped);
if (missing.length) {
log.error(
@ -60,20 +81,21 @@ export async function runCheckJestConfigsCli() {
);
if (fix) {
missing.forEach((file) => {
const template = file.endsWith(CONFIG_NAMES.unit)
? unitTestingTemplate
: integrationTestingTemplate;
for (const file of missing) {
const template =
Path.basename(file) === UNIT_CONFIG_NAME
? unitTestingTemplate
: integrationTestingTemplate;
const modulePath = path.dirname(file);
const modulePath = Path.dirname(file);
const content = Mustache.render(template, {
relToRoot: path.relative(modulePath, '.'),
relToRoot: Path.relative(modulePath, REPO_ROOT),
modulePath,
});
writeFileSync(file, content);
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`
@ -86,8 +108,8 @@ export async function runCheckJestConfigsCli() {
flags: {
boolean: ['fix'],
help: `
--fix Attempt to create missing config files
`,
--fix Attempt to create missing config files
`,
},
}
);