UNPKG

@backstage/cli

Version:

CLI for developing Backstage plugins and apps

414 lines (376 loc) • 11.7 kB
/* * Copyright 2020 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const fs = require('fs-extra'); 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 FRONTEND_ROLES = [ 'frontend', 'web-library', 'common-library', 'frontend-plugin', 'frontend-plugin-module', ]; const NODE_ROLES = [ 'backend', 'cli', 'node-library', 'backend-plugin', 'backend-plugin-module', ]; const envOptions = { oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), }; try { require.resolve('react-dom/client', { paths: [paths.targetRoot], }); process.env.HAS_REACT_DOM_CLIENT = true; } catch { /* 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', 'core-js', 'jest-.*', 'jsdom', 'knex', 'react', 'react-dom', 'highlight\\.js', 'prismjs', 'json-schema', 'react-use/lib', 'typescript', ].join('|'); // Provides additional config that's based on the role of the target package function getRoleConfig(role, pkgJson) { // Only Node.js package roles support native ESM modules, frontend and common // packages are always transpiled to CommonJS. const moduleOpts = NODE_ROLES.includes(role) ? { module: { ignoreDynamic: true, exportInteropAnnotation: true, }, } : undefined; const transform = { '\\.(mjs|cjs|js)$': [ require.resolve('./jestSwcTransform'), { ...moduleOpts, jsc: { parser: { syntax: 'ecmascript', }, }, }, ], '\\.jsx$': [ require.resolve('./jestSwcTransform'), { jsc: { parser: { syntax: 'ecmascript', jsx: true, }, transform: { react: { runtime: 'automatic', }, }, }, }, ], '\\.(mts|cts|ts)$': [ require.resolve('./jestSwcTransform'), { ...moduleOpts, jsc: { parser: { syntax: 'typescript', }, }, }, ], '\\.tsx$': [ require.resolve('./jestSwcTransform'), { jsc: { parser: { syntax: 'typescript', tsx: true, }, transform: { react: { runtime: 'automatic', }, }, }, }, ], '\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$': require.resolve('./jestFileTransform.js'), '\\.(yaml)$': require.resolve('./jestYamlTransform'), }; if (FRONTEND_ROLES.includes(role)) { return { testEnvironment: require.resolve('jest-environment-jsdom'), transform, }; } return { testEnvironment: require.resolve('jest-environment-node'), moduleFileExtensions: [...SRC_EXTS, 'json', 'node'], // Jest doesn't let us dynamically detect type=module per transformed file, // so we have to assume that if the entry point is ESM, all TS files are // ESM. // // This means you can't switch a package to type=module until all of its // monorepo dependencies are also type=module or does not contain any .ts // files. extensionsToTreatAsEsm: pkgJson.type === 'module' ? ['.ts', '.mts'] : ['.mts'], transform, }; } async function getProjectConfig(targetPath, extraConfig, extraOptions) { const configJsPath = path.resolve(targetPath, 'jest.config.js'); const configTsPath = path.resolve(targetPath, 'jest.config.ts'); // If the package has it's own jest config, we use that instead. if (await fs.pathExists(configJsPath)) { return require(configJsPath); } else if (await fs.pathExists(configTsPath)) { return require(configTsPath); } // 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 pkgJson = await fs.readJson(path.resolve(targetPath, 'package.json')); const options = { ...extraConfig, rootDir: path.resolve(targetPath, 'src'), moduleNameMapper: { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), }, // A bit more opinionated testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`], runtime: envOptions.oldTests ? undefined : require.resolve('./jestCachingModuleLoader'), transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`], ...getRoleConfig(pkgJson.backstage?.role, pkgJson), }; options.setupFilesAfterEnv = options.setupFilesAfterEnv || []; if ( extraOptions.rejectFrontendNetworkRequests && FRONTEND_ROLES.includes(pkgJson.backstage?.role) ) { // By adding this first we ensure that it's possible to for example override // fetch with a mock in a custom setup file options.setupFilesAfterEnv.unshift( require.resolve('./jestRejectNetworkRequests.js'), ); } if (options.testEnvironment === require.resolve('jest-environment-jsdom')) { // FIXME https://github.com/jsdom/jsdom/issues/1724 options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill')); } // 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, 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. if (!config.id) { const configHash = crypto .createHash('sha256') .update(version) .update(Buffer.alloc(1)) .update(JSON.stringify(config.transform).replaceAll(paths.targetRoot, '')) .digest('hex'); config.id = `backstage_cli_${configHash}`; } return config; } // This loads the root jest config, which in turn will either refer to a single // configuration for the current package, or a collection of configurations for // the target workspace packages async function getRootConfig() { const rootPkgJson = await fs.readJson( paths.resolveTargetRoot('package.json'), ); const baseCoverageConfig = { coverageDirectory: paths.resolveTarget('coverage'), coverageProvider: envOptions.oldTests ? 'v8' : 'babel', collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'], }; const { rejectFrontendNetworkRequests, ...rootOptions } = rootPkgJson.jest ?? {}; const extraRootOptions = { rejectFrontendNetworkRequests, }; 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, ...rootOptions, }, extraRootOptions, ); } const globalRootConfig = { ...baseCoverageConfig }; const globalProjectConfig = {}; for (const [key, value] of Object.entries(rootOptions)) { 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(paths.targetRoot, pattern)), ), ).then(_ => _.flat()); let projects = await Promise.all( projectPaths.flat().map(async projectPath => { const packagePath = path.resolve(projectPath, 'package.json'); if (!(await fs.pathExists(packagePath))) { return undefined; } // We check for the presence of "backstage-cli test" in the package test // script to determine whether a given package should be tested const packageData = await fs.readJson(packagePath); const testScript = packageData.scripts && packageData.scripts.test; const isSupportedTestScript = testScript?.includes('backstage-cli test') || testScript?.includes('backstage-cli package test'); if (testScript && isSupportedTestScript) { return await getProjectConfig( projectPath, { ...globalProjectConfig, displayName: packageData.name, }, extraRootOptions, ); } return undefined; }), ).then(cs => cs.filter(Boolean)); const cache = global.__backstageCli_jestSuccessCache; if (cache) { projects = await cache.filterConfigs(projects, globalRootConfig); } const watchProjectFilter = global.__backstageCli_watchProjectFilter; if (watchProjectFilter) { projects = await watchProjectFilter.filter(projects); } return { rootDir: paths.targetRoot, projects, testResultsProcessor: cache ? require.resolve('./jestCacheResultProcessor.cjs') : undefined, ...globalRootConfig, }; } module.exports = getRootConfig();