cli: rework jest configuration collection

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-09-27 16:02:55 +02:00
parent 2e32ba5031
commit 55b8b84fa3
3 changed files with 124 additions and 68 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': minor
---
**BREAKING**: The Jest configuration defined at `@backstage/cli/config/jest` no longer collects configuration defined in the `"jest"` field from all parent `package.json` files. Instead, it will only read and merge configuration from the `package.json` in the monorepo root if it exists, as well as the target package. In addition, configuration defined in the root `package.json` will now only be merged into each package configuration if it is a valid project-level configuration key.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
The Jest configuration will now search for a `src/setupTests.*` file with any valid script extension, not only `.ts`.
+114 -68
View File
@@ -19,6 +19,9 @@ const path = require('path');
const crypto = require('crypto');
const glob = require('util').promisify(require('glob'));
const { version } = require('../package.json');
const paths = require('@backstage/cli-common').findPaths(process.cwd());
const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs'];
const envOptions = {
oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS),
@@ -31,6 +34,75 @@ try {
/* ignored */
}
/**
* A list of config keys that are valid for project-level config.
* Jest will complain if we forward any other root configuration to the projects.
*
* @type {Array<keyof import('@jest/types').Config.ProjectConfig>}
*/
const projectConfigKeys = [
'automock',
'cache',
'cacheDirectory',
'clearMocks',
'collectCoverageFrom',
'coverageDirectory',
'coveragePathIgnorePatterns',
'cwd',
'dependencyExtractor',
'detectLeaks',
'detectOpenHandles',
'displayName',
'errorOnDeprecated',
'extensionsToTreatAsEsm',
'fakeTimers',
'filter',
'forceCoverageMatch',
'globalSetup',
'globalTeardown',
'globals',
'haste',
'id',
'injectGlobals',
'moduleDirectories',
'moduleFileExtensions',
'moduleNameMapper',
'modulePathIgnorePatterns',
'modulePaths',
'openHandlesTimeout',
'preset',
'prettierPath',
'resetMocks',
'resetModules',
'resolver',
'restoreMocks',
'rootDir',
'roots',
'runner',
'runtime',
'sandboxInjectedGlobals',
'setupFiles',
'setupFilesAfterEnv',
'skipFilter',
'skipNodeResolution',
'slowTestThreshold',
'snapshotResolver',
'snapshotSerializers',
'snapshotFormat',
'testEnvironment',
'testEnvironmentOptions',
'testMatch',
'testLocationInResults',
'testPathIgnorePatterns',
'testRegex',
'testRunner',
'transform',
'transformIgnorePatterns',
'watchPathIgnorePatterns',
'unmockedModulePathPatterns',
'workerIdleMemoryLimit',
];
const transformIgnorePattern = [
'@material-ui',
'ajv',
@@ -76,54 +148,11 @@ async function getProjectConfig(targetPath, extraConfig) {
return require(configTsPath);
}
// We read all "jest" config fields in package.json files all the way to the filesystem root.
// All configs are merged together to create the final config, with longer paths taking precedence.
// Jest config can be defined both in the root package.json and within each package. The root config
// gets forwarded to us through the `extraConfig` parameter, while the package config is read here.
// If they happen to be the same the keys will simply override each other.
// The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined.
const pkgJsonConfigs = [];
let closestPkgJson = undefined;
let currentPath = targetPath;
// Some confidence check to avoid infinite loop
for (let i = 0; i < 100; i++) {
const packagePath = path.resolve(currentPath, 'package.json');
const exists = fs.pathExistsSync(packagePath);
if (exists) {
try {
const data = fs.readJsonSync(packagePath);
if (!closestPkgJson) {
closestPkgJson = data;
}
if (data.jest) {
pkgJsonConfigs.unshift(data.jest);
}
} catch (error) {
throw new Error(
`Failed to parse package.json file reading jest configs, ${error}`,
);
}
}
const newPath = path.dirname(currentPath);
if (newPath === currentPath) {
break;
}
currentPath = newPath;
}
// This is an old deprecated option that is no longer used.
const transformModules = pkgJsonConfigs
.flatMap(conf => {
const modules = conf.transformModules || [];
delete conf.transformModules;
return modules;
})
.map(name => `${name}/`)
.join('|');
if (transformModules.length > 0) {
console.warn(
'The Backstage CLI jest transformModules option is no longer used and will be ignored. All modules are now always transformed.',
);
}
const pkgJson = await fs.readJson(path.resolve(targetPath, 'package.json'));
const options = {
...extraConfig,
@@ -191,14 +220,14 @@ async function getProjectConfig(targetPath, extraConfig) {
},
// A bit more opinionated
testMatch: ['**/*.test.{js,jsx,ts,tsx,mjs,cjs}'],
testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`],
runtime: envOptions.oldTests
? undefined
: require.resolve('./jestCachingModuleLoader'),
transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`],
...getRoleConfig(closestPkgJson?.backstage?.role),
...getRoleConfig(pkgJson.backstage?.role),
};
options.setupFilesAfterEnv = options.setupFilesAfterEnv || [];
@@ -208,12 +237,15 @@ async function getProjectConfig(targetPath, extraConfig) {
options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill'));
}
// Use src/setupTests.ts as the default location for configuring test env
if (fs.existsSync(path.resolve(targetPath, 'src/setupTests.ts'))) {
options.setupFilesAfterEnv.push('<rootDir>/setupTests.ts');
// Use src/setupTests.* as the default location for configuring test env
for (const ext of SRC_EXTS) {
if (fs.existsSync(path.resolve(targetPath, `src/setupTests.${ext}`))) {
options.setupFilesAfterEnv.push(`<rootDir>/setupTests.${ext}`);
break;
}
}
const config = Object.assign(options, ...pkgJsonConfigs);
const config = Object.assign(options, pkgJson.jest);
// The config id is a cache key that lets us share the jest cache across projects.
// If no explicit id was configured, generated one based on the configuration.
@@ -234,31 +266,44 @@ async function getProjectConfig(targetPath, extraConfig) {
// configuration for the current package, or a collection of configurations for
// the target workspace packages
async function getRootConfig() {
const targetPath = process.cwd();
const targetPackagePath = path.resolve(targetPath, 'package.json');
const exists = await fs.pathExists(targetPackagePath);
const rootPkgJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
const coverageConfig = {
coverageDirectory: path.resolve(targetPath, 'coverage'),
const baseCoverageConfig = {
coverageDirectory: paths.resolveTarget('coverage'),
coverageProvider: envOptions.oldTests ? 'v8' : 'babel',
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'],
};
if (!exists) {
return getProjectConfig(targetPath, coverageConfig);
const workspacePatterns =
rootPkgJson.workspaces && rootPkgJson.workspaces.packages;
// Check if we're running within a specific monorepo package. In that case just get the single project config.
if (!workspacePatterns || paths.targetRoot !== paths.targetDir) {
return getProjectConfig(paths.targetDir, {
...baseCoverageConfig,
...(rootPkgJson.jest ?? {}),
});
}
// Check whether the current package is a workspace root or not
const data = await fs.readJson(targetPackagePath);
const workspacePatterns = data.workspaces && data.workspaces.packages;
if (!workspacePatterns) {
return getProjectConfig(targetPath, coverageConfig);
const globalRootConfig = { ...baseCoverageConfig };
const globalProjectConfig = {};
for (const [key, value] of Object.entries(rootPkgJson.jest ?? {})) {
if (projectConfigKeys.includes(key)) {
globalProjectConfig[key] = value;
} else {
globalRootConfig[key] = value;
}
}
// If the target package is a workspace root, we find all packages in the
// workspace and load those in as separate jest projects instead.
const projectPaths = await Promise.all(
workspacePatterns.map(pattern => glob(path.join(targetPath, pattern))),
workspacePatterns.map(pattern =>
glob(path.join(paths.targetRoot, pattern)),
),
).then(_ => _.flat());
const configs = await Promise.all(
@@ -277,6 +322,7 @@ async function getRootConfig() {
testScript?.includes('backstage-cli package test');
if (testScript && isSupportedTestScript) {
return await getProjectConfig(projectPath, {
...globalProjectConfig,
displayName: packageData.name,
});
}
@@ -286,9 +332,9 @@ async function getRootConfig() {
).then(cs => cs.filter(Boolean));
return {
rootDir: targetPath,
rootDir: paths.targetRoot,
projects: configs,
...coverageConfig,
...globalRootConfig,
};
}