From 55f6eb8c6443c64816290afdc6837c275217b0a5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 22:11:43 +0100 Subject: [PATCH] Move config files to CLI modules with lazy proxies Move jest config files to cli-module-test-jest/config and node transform + webpack-public-path to cli-module-build/config. Replace originals in @backstage/cli/config with lazy proxies that forward to the appropriate module or throw if it is not installed. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli-module-build/.eslintrc.js | 4 +- .../cli-module-build/config/nodeTransform.cjs | 87 ++++ .../config/nodeTransformHooks.mjs | 294 ++++++++++++ .../config/webpack-public-path.js | 31 ++ packages/cli-module-build/package.json | 3 + .../src/lib/bundler/config.ts | 5 +- .../src/lib/runner/runBackend.ts | 5 +- .../src/tests/transforms/transforms.test.ts | 4 +- packages/cli-module-test-jest/.eslintrc.js | 4 +- .../config/getJestEnvironment.js | 49 ++ .../config/jest-environment-jsdom/index.js | 61 +++ packages/cli-module-test-jest/config/jest.js | 422 ++++++++++++++++++ .../config/jestCacheResultProcessor.cjs | 23 + .../config/jestCachingModuleLoader.js | 35 ++ .../config/jestFileTransform.js | 44 ++ .../config/jestRejectNetworkRequests.js | 70 +++ .../config/jestSucraseTransform.js | 87 ++++ .../config/jestSwcTransform.js | 44 ++ .../config/jestYamlTransform.js | 40 ++ packages/cli-module-test-jest/package.json | 26 +- .../src/commands/package/test.ts | 4 +- .../src/commands/repo/test.ts | 3 +- packages/cli/config/getJestEnvironment.js | 36 +- .../config/jest-environment-jsdom/index.js | 52 +-- packages/cli/config/jest.js | 409 +---------------- .../cli/config/jestCacheResultProcessor.cjs | 16 +- .../cli/config/jestCachingModuleLoader.js | 30 +- packages/cli/config/jestFileTransform.js | 39 +- .../cli/config/jestRejectNetworkRequests.js | 63 +-- packages/cli/config/jestSucraseTransform.js | 80 +--- packages/cli/config/jestSwcTransform.js | 37 +- packages/cli/config/jestYamlTransform.js | 35 +- packages/cli/config/nodeTransform.cjs | 80 +--- packages/cli/config/nodeTransformHooks.mjs | 282 +----------- packages/cli/config/webpack-public-path.js | 24 +- packages/cli/package.json | 2 + yarn.lock | 21 + 37 files changed, 1476 insertions(+), 1075 deletions(-) create mode 100644 packages/cli-module-build/config/nodeTransform.cjs create mode 100644 packages/cli-module-build/config/nodeTransformHooks.mjs create mode 100644 packages/cli-module-build/config/webpack-public-path.js create mode 100644 packages/cli-module-test-jest/config/getJestEnvironment.js create mode 100644 packages/cli-module-test-jest/config/jest-environment-jsdom/index.js create mode 100644 packages/cli-module-test-jest/config/jest.js create mode 100644 packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs create mode 100644 packages/cli-module-test-jest/config/jestCachingModuleLoader.js create mode 100644 packages/cli-module-test-jest/config/jestFileTransform.js create mode 100644 packages/cli-module-test-jest/config/jestRejectNetworkRequests.js create mode 100644 packages/cli-module-test-jest/config/jestSucraseTransform.js create mode 100644 packages/cli-module-test-jest/config/jestSwcTransform.js create mode 100644 packages/cli-module-test-jest/config/jestYamlTransform.js diff --git a/packages/cli-module-build/.eslintrc.js b/packages/cli-module-build/.eslintrc.js index e2a53a6ad2..a070dc55ee 100644 --- a/packages/cli-module-build/.eslintrc.js +++ b/packages/cli-module-build/.eslintrc.js @@ -1 +1,3 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + ignorePatterns: ['config/**', 'templates/**'], +}); diff --git a/packages/cli-module-build/config/nodeTransform.cjs b/packages/cli-module-build/config/nodeTransform.cjs new file mode 100644 index 0000000000..15bcdba9bc --- /dev/null +++ b/packages/cli-module-build/config/nodeTransform.cjs @@ -0,0 +1,87 @@ +/* + * Copyright 2024 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 { pathToFileURL } = require('node:url'); +const { transformSync } = require('@swc/core'); +const { addHook } = require('pirates'); +const { Module } = require('node:module'); + +// This hooks into module resolution and overrides imports of packages that +// exist in the linked workspace to instead be resolved from the linked workspace. +if (process.env.BACKSTAGE_CLI_LINKED_WORKSPACE) { + const { join: joinPath } = require('node:path'); + const { getPackagesSync } = require('@manypkg/get-packages'); + const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( + process.env.BACKSTAGE_CLI_LINKED_WORKSPACE, + ); + + // Matches all packages in the linked workspaces, as well as sub-path exports from them + const replacementRegex = new RegExp( + `^(?:${linkedPackages + .map(pkg => pkg.packageJson.name) + .join('|')})(?:/.*)?$`, + ); + + const origLoad = Module._load; + Module._load = function requireHook(request, parent) { + if (!replacementRegex.test(request)) { + return origLoad.call(this, request, parent); + } + + // The package import that we're overriding will always existing in the root + // node_modules of the linked workspace, so it's enough to override the + // parent paths with that single entry + return origLoad.call(this, request, { + ...parent, + paths: [joinPath(linkedRoot.dir, 'node_modules')], + }); + }; +} + +addHook( + (code, filename) => { + const transformed = transformSync(code, { + filename, + sourceMaps: 'inline', + module: { + type: 'commonjs', + ignoreDynamic: true, + }, + jsc: { + target: 'es2023', + parser: { + syntax: 'typescript', + }, + }, + }); + process.send?.({ type: 'watch', path: filename }); + return transformed.code; + }, + { extensions: ['.ts', '.cts'], ignoreNodeModules: true }, +); + +addHook( + (code, filename) => { + process.send?.({ type: 'watch', path: filename }); + return code; + }, + { extensions: ['.js', '.cjs'], ignoreNodeModules: true }, +); + +// Register module hooks, used by "type": "module" in package.json, .mjs and +// .mts files, as well as dynamic import(...)s, although dynamic imports will be +// handled be the CommonJS hooks in this file if what it points to is CommonJS. +Module.register('./nodeTransformHooks.mjs', pathToFileURL(__filename)); diff --git a/packages/cli-module-build/config/nodeTransformHooks.mjs b/packages/cli-module-build/config/nodeTransformHooks.mjs new file mode 100644 index 0000000000..592867e57a --- /dev/null +++ b/packages/cli-module-build/config/nodeTransformHooks.mjs @@ -0,0 +1,294 @@ +/* + * Copyright 2024 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. + */ + +import { dirname, extname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transformFile } from '@swc/core'; +import { isBuiltin } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +// @ts-check + +// No explicit file extension, no type in package.json +const DEFAULT_MODULE_FORMAT = 'commonjs'; + +// Source file extensions to look for when using bundle resolution strategy +const SRC_EXTS = ['.ts', '.js']; +const TS_EXTS = ['.ts', '.mts', '.cts']; +const moduleTypeTable = { + '.mjs': 'module', + '.mts': 'module', + '.cjs': 'commonjs', + '.cts': 'commonjs', + '.ts': undefined, + '.js': undefined, +}; + +/** @type {import('module').ResolveHook} */ +export async function resolve(specifier, context, nextResolve) { + // Built-in modules are handled by the default resolver + if (isBuiltin(specifier)) { + return nextResolve(specifier, context); + } + + const ext = extname(specifier); + + // Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below. + if (ext === '.json' && !context.importAttributes?.type) { + const jsonResult = await nextResolve(specifier, context); + return { + ...jsonResult, + format: 'commonjs', + importAttributes: { type: 'json' }, + }; + } + + // Anything else with an explicit extension is handled by the default + // resolver, except that we help determine the module type where needed. + if (ext !== '') { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // Other external modules are handled by the default resolver, but again we + // help determine the module type where needed. + if (!specifier.startsWith('.')) { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // The rest of this function handles the case of resolving imports that do not + // specify any extension and might point to a directory with an `index.*` + // file. We resolve those using the same logic as most JS bundlers would, with + // the addition of checking if there's an explicit module format listed in the + // closest `package.json` file. + // + // We use a bundle resolution strategy in order to keep code consistent across + // Backstage codebases that contains code both for Web and Node.js, and to + // support packages with common code that can be used in both environments. + try { + // This is expected to throw, but in the event that this module specifier is + // supported we prefer to use the default resolver. + return await nextResolve(specifier, context); + } catch (error) { + if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { + const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`; + const resolved = await resolveWithoutExt(spec, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } else if (error.code === 'ERR_MODULE_NOT_FOUND') { + const resolved = await resolveWithoutExt(specifier, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } + + // Unexpected error or no resolution found + throw error; + } +} + +/** + * Populates the `format` field in the resolved object based on the closest `package.json` file. + * + * @param {import('module').ResolveFnOutput} resolved + * @returns {Promise} + */ +async function withDetectedModuleType(resolved) { + // Already has an explicit format + if (resolved.format) { + return resolved; + } + // Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default. + if (resolved.format === null) { + return { ...resolved, format: DEFAULT_MODULE_FORMAT }; + } + + const ext = extname(resolved.url); + + const explicitFormat = moduleTypeTable[ext]; + if (explicitFormat) { + return { + ...resolved, + format: explicitFormat, + }; + } + + // Under normal circumstances .js files should reliably have a format and so + // we should only reach this point for .ts files. However, if additional + // custom loaders are being used the format may not be detected for .js files + // either. As such we don't restrict the file format at this point. + + // TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring + const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url)); + if (!packageJsonPath) { + return resolved; + } + + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + return { + ...resolved, + format: packageJson.type ?? DEFAULT_MODULE_FORMAT, + }; +} + +/** + * Find the closest package.json file from the given path. + * + * TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable. + * @param {string} startPath + * @returns {Promise} + */ +async function findPackageJSON(startPath) { + let path = startPath; + + // Some confidence check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + if (existsSync(packagePath)) { + return packagePath; + } + + const newPath = dirname(path); + if (newPath === path) { + return undefined; + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for package.json at ${startPath}`, + ); +} + +/** @type {import('module').ResolveHook} */ +async function resolveWithoutExt(specifier, context, nextResolve) { + for (const tryExt of SRC_EXTS) { + try { + const resolved = await nextResolve(specifier + tryExt, { + ...context, + format: 'commonjs', + }); + return { + ...resolved, + format: moduleTypeTable[tryExt] ?? resolved.format, + }; + } catch { + /* ignore */ + } + } + return undefined; +} + +/** @type {import('module').LoadHook} */ +export async function load(url, context, nextLoad) { + // Non-file URLs are handled by the default loader + if (!url.startsWith('file://')) { + return nextLoad(url, context); + } + + // JSON files loaded as CommonJS are handled by this custom loader, because + // the default one doesn't work. For JSON loading to work we'd need the + // synchronous hooks that aren't supported yet, or avoid using the CommonJS + // compatibility. + if ( + context.format === 'commonjs' && + context.importAttributes?.type === 'json' + ) { + try { + // TODO(Rugvip): Make sure this is valid JSON + const content = await readFile(fileURLToPath(url), 'utf8'); + return { + source: `module.exports = (${content})`, + format: 'commonjs', + shortCircuit: true, + }; + } catch { + // Let the default loader generate the error + return nextLoad(url, context); + } + } + + const ext = extname(url); + + // Non-TS files are handled by the default loader + if (!TS_EXTS.includes(ext)) { + return nextLoad(url, context); + } + + const format = context.format ?? DEFAULT_MODULE_FORMAT; + + // We have two choices at this point, we can either transform CommonJS files + // and return the transformed source code, or let the default loader handle + // them. If we transform them ourselves we will enter CommonJS compatibility + // mode in the new module system in Node.js, this effectively means all + // CommonJS loaded via `require` calls from this point will all be treated as + // if it was loaded via `import` calls from modules. + // + // The CommonJS compatibility layer will try to identify named exports and + // make them available directly, which is convenient as it avoids things like + // `import(...).then(m => m.default.foo)`, allowing you to instead write + // `import(...).then(m => m.foo)`. The compatibility layer doesn't always work + // all that well though, and can lead to module loading issues in many cases, + // especially for older code. + + // This `if` block opts-out of using CommonJS compatibility mode by default, + // and instead leaves it to our existing loader to transform CommonJS. We do + // however use compatibility mode for the more explicit .cts file extension, + // allows for a way to opt-in to the new behavior. + // + // TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead + if (format === 'commonjs' && ext !== '.cts') { + return nextLoad(url, { ...context, format }); + } + + // If the Node.js version we're running supports TypeScript, i.e. type + // stripping, we hand over to the default loader. This is done for all cases + // except if we're loading a .ts file that's been resolved to CommonJS format. + // This is because these files aren't actually CommonJS in the Backstage build + // system, and need to be transformed to CommonJS. + if ( + format === 'module-typescript' || + (format === 'module-commonjs' && ext !== '.ts') + ) { + return nextLoad(url, { ...context, format }); + } + + const transformed = await transformFile(fileURLToPath(url), { + sourceMaps: 'inline', + module: { + type: format === 'module' ? 'es6' : 'commonjs', + ignoreDynamic: true, + + // This helps the Node.js CommonJS compat layer identify named exports. + exportInteropAnnotation: true, + }, + jsc: { + target: 'es2023', + parser: { + syntax: 'typescript', + }, + }, + }); + + return { + ...context, + shortCircuit: true, + source: transformed.code, + format, + responseURL: url, + }; +} diff --git a/packages/cli-module-build/config/webpack-public-path.js b/packages/cli-module-build/config/webpack-public-path.js new file mode 100644 index 0000000000..3e14e96eb9 --- /dev/null +++ b/packages/cli-module-build/config/webpack-public-path.js @@ -0,0 +1,31 @@ +/* + * Copyright 2024 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. + */ + +// This script is used to pick up and set the public path of the Webpack bundle +// at runtime. The meta tag is injected by the app build, but only present in +// the `index.html.tmpl` file. The runtime value of the meta tag is populated by +// the app backend, when it templates the final `index.html` file. +// +// This is needed for additional chunks to use the correct public path, and it +// is not possible to set the `__webpack_public_path__` variable outside of the +// build itself. The Webpack output also does not read any tags or +// similar, this seems to be the only way to dynamically configure the public +// path at runtime. +const el = document.querySelector('meta[name="backstage-public-path"]'); +const path = el?.getAttribute('content'); +if (path) { + __webpack_public_path__ = path; +} diff --git a/packages/cli-module-build/package.json b/packages/cli-module-build/package.json index 7f9fa0b37b..519b24b701 100644 --- a/packages/cli-module-build/package.json +++ b/packages/cli-module-build/package.json @@ -23,6 +23,7 @@ "files": [ "dist", "bin", + "config", "templates" ], "scripts": { @@ -50,6 +51,7 @@ "@rspack/core": "^1.4.11", "@rspack/dev-server": "^1.1.4", "@rspack/plugin-react-refresh": "^1.4.3", + "@swc/core": "^1.15.6", "bfj": "^9.0.2", "buffer": "^6.0.3", "chalk": "^4.0.0", @@ -70,6 +72,7 @@ "node-stdlib-browser": "^1.3.1", "npm-packlist": "^5.0.0", "p-queue": "^6.6.2", + "pirates": "^4.0.6", "postcss": "^8.1.0", "postcss-import": "^16.1.0", "process": "^0.11.10", diff --git a/packages/cli-module-build/src/lib/bundler/config.ts b/packages/cli-module-build/src/lib/bundler/config.ts index 08856cd63e..cee7ba6819 100644 --- a/packages/cli-module-build/src/lib/bundler/config.ts +++ b/packages/cli-module-build/src/lib/bundler/config.ts @@ -29,7 +29,7 @@ import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack'; import fs from 'fs-extra'; import { optimization as optimizationConfig } from './optimization'; import pickBy from 'lodash/pickBy'; -import { runOutput, targetPaths } from '@backstage/cli-common'; +import { findOwnPaths, runOutput, targetPaths } from '@backstage/cli-common'; import { transforms } from './transforms'; import { version } from '../../../package.json'; @@ -330,7 +330,8 @@ export async function createConfig( devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map', context: paths.targetPath, entry: [ - require.resolve('@backstage/cli/config/webpack-public-path'), + /* eslint-disable-next-line no-restricted-syntax */ + findOwnPaths(__dirname).resolve('config/webpack-public-path'), ...(options.additionalEntryPoints ?? []), paths.targetEntry, ], diff --git a/packages/cli-module-build/src/lib/runner/runBackend.ts b/packages/cli-module-build/src/lib/runner/runBackend.ts index 22fd50b3f4..73c3fe08c7 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -21,14 +21,15 @@ import { IpcServer, ServerDataStore } from '../ipc'; import debounce from 'lodash/debounce'; import { fileURLToPath } from 'node:url'; import { isAbsolute as isAbsolutePath } from 'node:path'; -import { targetPaths } from '@backstage/cli-common'; +import { findOwnPaths, targetPaths } from '@backstage/cli-common'; import spawn from 'cross-spawn'; const loaderArgs = [ '--enable-source-maps', '--require', - require.resolve('@backstage/cli/config/nodeTransform.cjs'), + /* eslint-disable-next-line no-restricted-syntax */ + findOwnPaths(__dirname).resolve('config/nodeTransform.cjs'), // TODO: Support modules, although there's currently no way to load them since import() is transpiled tp require() ]; diff --git a/packages/cli-module-build/src/tests/transforms/transforms.test.ts b/packages/cli-module-build/src/tests/transforms/transforms.test.ts index 9e4fca8c63..40f40fac2d 100644 --- a/packages/cli-module-build/src/tests/transforms/transforms.test.ts +++ b/packages/cli-module-build/src/tests/transforms/transforms.test.ts @@ -16,6 +16,7 @@ import { execFileSync } from 'node:child_process'; import { resolve as resolvePath } from 'node:path'; +import { findOwnPaths } from '@backstage/cli-common'; import { Output, buildPackage } from '../../lib/builder'; const exportValues = { @@ -55,7 +56,8 @@ function loadFixture(fixture: string) { 'node', [ '--import', - '@backstage/cli/config/nodeTransform.cjs', + /* eslint-disable-next-line no-restricted-syntax */ + findOwnPaths(__dirname).resolve('config/nodeTransform.cjs'), resolvePath(__dirname, `__fixtures__/${fixture}`), ], { encoding: 'utf8' }, diff --git a/packages/cli-module-test-jest/.eslintrc.js b/packages/cli-module-test-jest/.eslintrc.js index e2a53a6ad2..6f313a7a5e 100644 --- a/packages/cli-module-test-jest/.eslintrc.js +++ b/packages/cli-module-test-jest/.eslintrc.js @@ -1 +1,3 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + ignorePatterns: ['config/**'], +}); diff --git a/packages/cli-module-test-jest/config/getJestEnvironment.js b/packages/cli-module-test-jest/config/getJestEnvironment.js new file mode 100644 index 0000000000..aac2f79021 --- /dev/null +++ b/packages/cli-module-test-jest/config/getJestEnvironment.js @@ -0,0 +1,49 @@ +/* + * Copyright 2025 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. + */ + +function getJestMajorVersion() { + const jestVersion = require('jest/package.json').version; + const majorVersion = parseInt(jestVersion.split('.')[0], 10); + return majorVersion; +} + +function getJestEnvironment() { + const majorVersion = getJestMajorVersion(); + + if (majorVersion >= 30) { + try { + require.resolve('@jest/environment-jsdom-abstract'); + require.resolve('jsdom'); + } catch { + throw new Error( + 'Jest 30+ requires @jest/environment-jsdom-abstract and jsdom. ' + + 'Please install them as dev dependencies.', + ); + } + return require.resolve('./jest-environment-jsdom'); + } + try { + require.resolve('jest-environment-jsdom'); + } catch { + throw new Error( + 'Jest 29 requires jest-environment-jsdom. ' + + 'Please install it as a dev dependency.', + ); + } + return require.resolve('jest-environment-jsdom'); +} + +module.exports = { getJestMajorVersion, getJestEnvironment }; diff --git a/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js b/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js new file mode 100644 index 0000000000..9d8b76eaf5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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 JSDOMEnvironment = require('@jest/environment-jsdom-abstract').default; +const jsdom = require('jsdom'); + +/** + * A custom JSDOM environment that extends the abstract base and applies + * fixes for Web API globals that are missing or incorrectly implemented + * in JSDOM. + * + * Based on https://github.com/mswjs/jest-fixed-jsdom + */ +class FixedJSDOMEnvironment extends JSDOMEnvironment { + constructor(config, context) { + super(config, context, jsdom); + + // Fix Web API globals that JSDOM doesn't properly expose + this.global.TextDecoder = TextDecoder; + this.global.TextEncoder = TextEncoder; + this.global.TextDecoderStream = TextDecoderStream; + this.global.TextEncoderStream = TextEncoderStream; + this.global.ReadableStream = ReadableStream; + + this.global.Blob = Blob; + this.global.Headers = Headers; + this.global.FormData = FormData; + this.global.Request = Request; + this.global.Response = Response; + this.global.fetch = fetch; + this.global.AbortController = AbortController; + this.global.AbortSignal = AbortSignal; + this.global.structuredClone = structuredClone; + this.global.URL = URL; + this.global.URLSearchParams = URLSearchParams; + + this.global.BroadcastChannel = BroadcastChannel; + this.global.TransformStream = TransformStream; + this.global.WritableStream = WritableStream; + + // Needed to ensure `e instanceof Error` works as expected with errors thrown from + // any of the native APIs above. Without this, the JSDOM `Error` is what the test + // code will use for comparison with `e`, which fails the instanceof check. + this.global.Error = Error; + } +} + +module.exports = FixedJSDOMEnvironment; diff --git a/packages/cli-module-test-jest/config/jest.js b/packages/cli-module-test-jest/config/jest.js new file mode 100644 index 0000000000..8a88483d42 --- /dev/null +++ b/packages/cli-module-test-jest/config/jest.js @@ -0,0 +1,422 @@ +/* + * 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('node:path'); +const crypto = require('node:crypto'); +const glob = require('node:util').promisify(require('glob')); +const { version } = require('../package.json'); +const paths = require('@backstage/cli-common').findPaths(process.cwd()); +const { + getJestEnvironment, + getJestMajorVersion, +} = require('./getJestEnvironment'); + +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', + 'cli-module', + '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} + */ +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: getJestEnvironment(), + // The caching module loader is only used to speed up frontend tests, + // as it breaks real dynamic imports of ESM modules. + runtime: envOptions.oldTests + ? undefined + : require.resolve('./jestCachingModuleLoader'), + 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(',')}}`], + + 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 === getJestEnvironment() && + getJestMajorVersion() < 30 // Only needed when not running the custom env for Jest 30+ + ) { + // 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(`/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 ws = rootPkgJson.workspaces; + const workspacePatterns = Array.isArray(ws) ? ws : ws?.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(); diff --git a/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs b/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs new file mode 100644 index 0000000000..4af401d8b5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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. + */ + +module.exports = async results => { + const cache = global.__backstageCli_jestSuccessCache; + if (cache) { + await cache.reportResults(results); + } + return results; +}; diff --git a/packages/cli-module-test-jest/config/jestCachingModuleLoader.js b/packages/cli-module-test-jest/config/jestCachingModuleLoader.js new file mode 100644 index 0000000000..5652ff67e7 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestCachingModuleLoader.js @@ -0,0 +1,35 @@ +/* + * Copyright 2022 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. + */ + +// 'jest-runtime' is included with jest and should be kept in sync with the installed jest version +// eslint-disable-next-line @backstage/no-undeclared-imports +const { default: JestRuntime } = require('jest-runtime'); + +module.exports = class CachingJestRuntime extends JestRuntime { + constructor(config, ...restArgs) { + super(config, ...restArgs); + this.allowLoadAsEsm = config.extensionsToTreatAsEsm.includes('.mts'); + } + + // Unfortunately we need to use this unstable API to make sure that .js files + // are only loaded as modules where ESM is supported, i.e. Node.js packages. + unstable_shouldLoadAsEsm(path, ...restArgs) { + if (!this.allowLoadAsEsm) { + return false; + } + return super.unstable_shouldLoadAsEsm(path, ...restArgs); + } +}; diff --git a/packages/cli-module-test-jest/config/jestFileTransform.js b/packages/cli-module-test-jest/config/jestFileTransform.js new file mode 100644 index 0000000000..ad1c37a4f5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestFileTransform.js @@ -0,0 +1,44 @@ +/* + * 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 path = require('node:path'); + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.icon\.svg$/)) { + return { + code: `const React = require('react'); + const SvgIcon = require('@material-ui/core/SvgIcon').default; + module.exports = { + __esModule: true, + default: props => React.createElement(SvgIcon, props, { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }) + };`, + }; + } + + return { code: `module.exports = ${assetFilename};` }; + }, +}; diff --git a/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js b/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js new file mode 100644 index 0000000000..f6f189a60c --- /dev/null +++ b/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js @@ -0,0 +1,70 @@ +/* + * Copyright 2024 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 http = require('node:http'); +const https = require('node:https'); + +const errorMessage = 'Network requests are not allowed in tests'; + +const origHttpAgent = http.globalAgent; +const origHttpsAgent = https.globalAgent; +const origFetch = global.fetch; +const origXMLHttpRequest = global.XMLHttpRequest; + +http.globalAgent = new http.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +https.globalAgent = new https.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +const BLOCKING_FETCH_SYMBOL = Symbol.for( + 'backstage.jestRejectNetworkRequests.blockingFetch', +); + +if (global.fetch) { + const blockingFetch = async (input, init) => { + // If global.fetch still has our marker, block the request + if (global.fetch[BLOCKING_FETCH_SYMBOL]) { + throw new Error(errorMessage); + } + // MSW (or something else) wrapped us - pass through + return origFetch(input, init); + }; + blockingFetch[BLOCKING_FETCH_SYMBOL] = true; + global.fetch = blockingFetch; +} + +if (global.XMLHttpRequest) { + global.XMLHttpRequest = class { + constructor() { + throw new Error(errorMessage); + } + }; +} + +// Reset overrides after each suite to make sure we don't pollute the test environment +afterAll(() => { + http.globalAgent = origHttpAgent; + https.globalAgent = origHttpsAgent; + global.fetch = origFetch; + global.XMLHttpRequest = origXMLHttpRequest; +}); diff --git a/packages/cli-module-test-jest/config/jestSucraseTransform.js b/packages/cli-module-test-jest/config/jestSucraseTransform.js new file mode 100644 index 0000000000..621b9ee297 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestSucraseTransform.js @@ -0,0 +1,87 @@ +/* + * Copyright 2021 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 { createHash } = require('node:crypto'); +const { transform } = require('sucrase'); +const sucrasePkg = require('sucrase/package.json'); + +const ESM_REGEX = /\b(?:import|export)\b/; + +function createTransformer(config) { + const process = (source, filePath) => { + let transforms; + + if (filePath.endsWith('.esm.js')) { + transforms = ['imports']; + } else if (filePath.endsWith('.js')) { + // This is a very rough filter to avoid transforming things that we quickly + // can be sure are definitely not ESM modules. + if (ESM_REGEX.test(source)) { + transforms = ['imports', 'jsx']; // JSX within .js is currently allowed + } + } else if (filePath.endsWith('.jsx')) { + transforms = ['jsx', 'imports']; + } else if (filePath.endsWith('.ts')) { + transforms = ['typescript', 'imports']; + } else if (filePath.endsWith('.tsx')) { + transforms = ['typescript', 'jsx', 'imports']; + } + + // Only apply the jest transform to the test files themselves + if (transforms && filePath.includes('.test.')) { + transforms.push('jest'); + } + + if (transforms) { + const { code, sourceMap: map } = transform(source, { + transforms, + filePath, + disableESTransforms: true, + sourceMapOptions: { + compiledFilename: filePath, + }, + }); + if (config.enableSourceMaps) { + const b64 = Buffer.from(JSON.stringify(map), 'utf8').toString('base64'); + const suffix = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`; + // Include both inline and object source maps, as inline source maps are + // needed for support of some editor integrations. + return { code: `${code}\n${suffix}`, map }; + } + // We only return the `map` result if source maps are enabled, as they + // have a negative impact on the coverage accuracy. + return { code }; + } + + return { code: source }; + }; + + const getCacheKey = sourceText => { + return createHash('sha256') + .update(sourceText) + .update(Buffer.alloc(1)) + .update(sucrasePkg.version) + .update(Buffer.alloc(1)) + .update(JSON.stringify(config)) + .update(Buffer.alloc(1)) + .update('1') // increment whenever the transform logic in this file changes + .digest('hex'); + }; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/config/jestSwcTransform.js b/packages/cli-module-test-jest/config/jestSwcTransform.js new file mode 100644 index 0000000000..4183349554 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestSwcTransform.js @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 { createTransformer: createSwcTransformer } = require('@swc/jest'); + +const ESM_REGEX = /\b(?:import|export)\b/; + +function createTransformer(config) { + const swcTransformer = createSwcTransformer({ + inputSourceMap: false, + ...config, + }); + const process = (source, filePath, jestOptions) => { + // Skip transformation of .js files without ESM syntax, we never transform from CJS to ESM + if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { + return { code: source }; + } + + // Skip transformation of .mjs files, they should only be used if ESM support is available + if (jestOptions.supportsStaticESM && filePath.endsWith('.mjs')) { + return { code: source }; + } + + return swcTransformer.process(source, filePath, jestOptions); + }; + + const getCacheKey = swcTransformer.getCacheKey; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/config/jestYamlTransform.js b/packages/cli-module-test-jest/config/jestYamlTransform.js new file mode 100644 index 0000000000..7a05274add --- /dev/null +++ b/packages/cli-module-test-jest/config/jestYamlTransform.js @@ -0,0 +1,40 @@ +/* + * Copyright 2022 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 yaml = require('yaml'); +const crypto = require('node:crypto'); + +function createTransformer(config) { + const process = source => { + const json = JSON.stringify(yaml.parse(source), null, 2); + return { code: `module.exports = ${json}`, map: null }; + }; + + const getCacheKey = sourceText => { + return crypto + .createHash('sha256') + .update(sourceText) + .update(Buffer.alloc(1)) + .update(JSON.stringify(config)) + .update(Buffer.alloc(1)) + .update('1') // increment whenever the transform logic in this file changes + .digest('hex'); + }; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/package.json b/packages/cli-module-test-jest/package.json index b02167a934..f83558327c 100644 --- a/packages/cli-module-test-jest/package.json +++ b/packages/cli-module-test-jest/package.json @@ -21,7 +21,8 @@ "types": "src/index.ts", "files": [ "dist", - "bin" + "bin", + "config" ], "scripts": { "build": "backstage-cli package build", @@ -34,14 +35,35 @@ "dependencies": { "@backstage/cli-common": "workspace:^", "@backstage/cli-node": "workspace:^", + "@swc/core": "^1.15.6", + "@swc/jest": "^0.2.39", "cleye": "^2.3.0", + "cross-fetch": "^4.0.0", + "fs-extra": "^11.2.0", + "glob": "^7.1.7", + "jest-css-modules": "^2.1.0", + "sucrase": "^3.20.2", "yargs": "^16.2.0" }, "devDependencies": { "@backstage/cli": "workspace:^" }, "peerDependencies": { - "jest-cli": "^29.0.0 || ^30.0.0" + "@jest/environment-jsdom-abstract": "^30.0.0", + "jest-cli": "^29.0.0 || ^30.0.0", + "jest-environment-jsdom": "*", + "jsdom": "^27.1.0" + }, + "peerDependenciesMeta": { + "@jest/environment-jsdom-abstract": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jsdom": { + "optional": true + } }, "bin": "bin/cli-module-test-jest" } diff --git a/packages/cli-module-test-jest/src/commands/package/test.ts b/packages/cli-module-test-jest/src/commands/package/test.ts index 6c9ad50e5b..b260601f8e 100644 --- a/packages/cli-module-test-jest/src/commands/package/test.ts +++ b/packages/cli-module-test-jest/src/commands/package/test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { runCheck } from '@backstage/cli-common'; +import { findOwnPaths, runCheck } from '@backstage/cli-common'; import type { CliCommandContext } from '@backstage/cli-node'; function includesAnyOf(hayStack: string[], ...needles: string[]) { @@ -30,7 +30,7 @@ export default async ({ args }: CliCommandContext) => { // Only include our config if caller isn't passing their own config if (!includesAnyOf(args, '-c', '--config')) { /* eslint-disable-next-line no-restricted-syntax */ - args.push('--config', require.resolve('@backstage/cli/config/jest')); + args.push('--config', findOwnPaths(__dirname).resolve('config/jest.js')); } if (!includesAnyOf(args, '--no-passWithNoTests', '--passWithNoTests=false')) { diff --git a/packages/cli-module-test-jest/src/commands/repo/test.ts b/packages/cli-module-test-jest/src/commands/repo/test.ts index e45df2bcfc..806d88965b 100644 --- a/packages/cli-module-test-jest/src/commands/repo/test.ts +++ b/packages/cli-module-test-jest/src/commands/repo/test.ts @@ -25,6 +25,7 @@ import { relative as relativePath } from 'node:path'; import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node'; import { + findOwnPaths, runCheck, runOutput, targetPaths, @@ -181,7 +182,7 @@ export default async ({ args, info }: CliCommandContext) => { // Only include our config if caller isn't passing their own config if (!hasFlags('-c', '--config')) { /* eslint-disable-next-line no-restricted-syntax */ - args.push('--config', require.resolve('@backstage/cli/config/jest')); + args.push('--config', findOwnPaths(__dirname).resolve('config/jest.js')); } if (!hasFlags('--passWithNoTests')) { diff --git a/packages/cli/config/getJestEnvironment.js b/packages/cli/config/getJestEnvironment.js index aac2f79021..e6fb66f407 100644 --- a/packages/cli/config/getJestEnvironment.js +++ b/packages/cli/config/getJestEnvironment.js @@ -14,36 +14,14 @@ * limitations under the License. */ -function getJestMajorVersion() { - const jestVersion = require('jest/package.json').version; - const majorVersion = parseInt(jestVersion.split('.')[0], 10); - return majorVersion; -} - -function getJestEnvironment() { - const majorVersion = getJestMajorVersion(); - - if (majorVersion >= 30) { - try { - require.resolve('@jest/environment-jsdom-abstract'); - require.resolve('jsdom'); - } catch { - throw new Error( - 'Jest 30+ requires @jest/environment-jsdom-abstract and jsdom. ' + - 'Please install them as dev dependencies.', - ); - } - return require.resolve('./jest-environment-jsdom'); - } - try { - require.resolve('jest-environment-jsdom'); - } catch { +try { + module.exports = require('@backstage/cli-module-test-jest/config/getJestEnvironment'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { throw new Error( - 'Jest 29 requires jest-environment-jsdom. ' + - 'Please install it as a dev dependency.', + '@backstage/cli-module-test-jest is required to use the jest environment configuration. ' + + 'Please install it as a dependency.', ); } - return require.resolve('jest-environment-jsdom'); + throw e; } - -module.exports = { getJestMajorVersion, getJestEnvironment }; diff --git a/packages/cli/config/jest-environment-jsdom/index.js b/packages/cli/config/jest-environment-jsdom/index.js index 9d8b76eaf5..f9be215d76 100644 --- a/packages/cli/config/jest-environment-jsdom/index.js +++ b/packages/cli/config/jest-environment-jsdom/index.js @@ -14,48 +14,14 @@ * limitations under the License. */ -const JSDOMEnvironment = require('@jest/environment-jsdom-abstract').default; -const jsdom = require('jsdom'); - -/** - * A custom JSDOM environment that extends the abstract base and applies - * fixes for Web API globals that are missing or incorrectly implemented - * in JSDOM. - * - * Based on https://github.com/mswjs/jest-fixed-jsdom - */ -class FixedJSDOMEnvironment extends JSDOMEnvironment { - constructor(config, context) { - super(config, context, jsdom); - - // Fix Web API globals that JSDOM doesn't properly expose - this.global.TextDecoder = TextDecoder; - this.global.TextEncoder = TextEncoder; - this.global.TextDecoderStream = TextDecoderStream; - this.global.TextEncoderStream = TextEncoderStream; - this.global.ReadableStream = ReadableStream; - - this.global.Blob = Blob; - this.global.Headers = Headers; - this.global.FormData = FormData; - this.global.Request = Request; - this.global.Response = Response; - this.global.fetch = fetch; - this.global.AbortController = AbortController; - this.global.AbortSignal = AbortSignal; - this.global.structuredClone = structuredClone; - this.global.URL = URL; - this.global.URLSearchParams = URLSearchParams; - - this.global.BroadcastChannel = BroadcastChannel; - this.global.TransformStream = TransformStream; - this.global.WritableStream = WritableStream; - - // Needed to ensure `e instanceof Error` works as expected with errors thrown from - // any of the native APIs above. Without this, the JSDOM `Error` is what the test - // code will use for comparison with `e`, which fails the instanceof check. - this.global.Error = Error; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jest-environment-jsdom'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest JSDOM environment. ' + + 'Please install it as a dependency.', + ); } + throw e; } - -module.exports = FixedJSDOMEnvironment; diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 8a88483d42..e06d0e319e 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -14,409 +14,14 @@ * limitations under the License. */ -const fs = require('fs-extra'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const glob = require('node:util').promisify(require('glob')); -const { version } = require('../package.json'); -const paths = require('@backstage/cli-common').findPaths(process.cwd()); -const { - getJestEnvironment, - getJestMajorVersion, -} = require('./getJestEnvironment'); - -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', - 'cli-module', - '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} - */ -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: getJestEnvironment(), - // The caching module loader is only used to speed up frontend tests, - // as it breaks real dynamic imports of ESM modules. - runtime: envOptions.oldTests - ? undefined - : require.resolve('./jestCachingModuleLoader'), - 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(',')}}`], - - 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'), + module.exports = require('@backstage/cli-module-test-jest/config/jest'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use this jest configuration. ' + + 'Please install it as a dependency.', ); } - - if ( - options.testEnvironment === getJestEnvironment() && - getJestMajorVersion() < 30 // Only needed when not running the custom env for Jest 30+ - ) { - // 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(`/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; + throw e; } - -// 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 ws = rootPkgJson.workspaces; - const workspacePatterns = Array.isArray(ws) ? ws : ws?.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(); diff --git a/packages/cli/config/jestCacheResultProcessor.cjs b/packages/cli/config/jestCacheResultProcessor.cjs index 4af401d8b5..58217428cc 100644 --- a/packages/cli/config/jestCacheResultProcessor.cjs +++ b/packages/cli/config/jestCacheResultProcessor.cjs @@ -14,10 +14,14 @@ * limitations under the License. */ -module.exports = async results => { - const cache = global.__backstageCli_jestSuccessCache; - if (cache) { - await cache.reportResults(results); +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestCacheResultProcessor.cjs'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest cache result processor. ' + + 'Please install it as a dependency.', + ); } - return results; -}; + throw e; +} diff --git a/packages/cli/config/jestCachingModuleLoader.js b/packages/cli/config/jestCachingModuleLoader.js index 5652ff67e7..2b6da11007 100644 --- a/packages/cli/config/jestCachingModuleLoader.js +++ b/packages/cli/config/jestCachingModuleLoader.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * Copyright 2023 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. @@ -14,22 +14,14 @@ * limitations under the License. */ -// 'jest-runtime' is included with jest and should be kept in sync with the installed jest version -// eslint-disable-next-line @backstage/no-undeclared-imports -const { default: JestRuntime } = require('jest-runtime'); - -module.exports = class CachingJestRuntime extends JestRuntime { - constructor(config, ...restArgs) { - super(config, ...restArgs); - this.allowLoadAsEsm = config.extensionsToTreatAsEsm.includes('.mts'); +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestCachingModuleLoader'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest caching module loader. ' + + 'Please install it as a dependency.', + ); } - - // Unfortunately we need to use this unstable API to make sure that .js files - // are only loaded as modules where ESM is supported, i.e. Node.js packages. - unstable_shouldLoadAsEsm(path, ...restArgs) { - if (!this.allowLoadAsEsm) { - return false; - } - return super.unstable_shouldLoadAsEsm(path, ...restArgs); - } -}; + throw e; +} diff --git a/packages/cli/config/jestFileTransform.js b/packages/cli/config/jestFileTransform.js index ad1c37a4f5..00e1c22815 100644 --- a/packages/cli/config/jestFileTransform.js +++ b/packages/cli/config/jestFileTransform.js @@ -14,31 +14,14 @@ * limitations under the License. */ -const path = require('node:path'); - -module.exports = { - process(src, filename) { - const assetFilename = JSON.stringify(path.basename(filename)); - - if (filename.match(/\.icon\.svg$/)) { - return { - code: `const React = require('react'); - const SvgIcon = require('@material-ui/core/SvgIcon').default; - module.exports = { - __esModule: true, - default: props => React.createElement(SvgIcon, props, { - $$typeof: Symbol.for('react.element'), - type: 'svg', - ref: ref, - key: null, - props: Object.assign({}, props, { - children: ${assetFilename} - }) - }) - };`, - }; - } - - return { code: `module.exports = ${assetFilename};` }; - }, -}; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestFileTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest file transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; +} diff --git a/packages/cli/config/jestRejectNetworkRequests.js b/packages/cli/config/jestRejectNetworkRequests.js index f6f189a60c..c975257b8c 100644 --- a/packages/cli/config/jestRejectNetworkRequests.js +++ b/packages/cli/config/jestRejectNetworkRequests.js @@ -14,57 +14,14 @@ * limitations under the License. */ -const http = require('node:http'); -const https = require('node:https'); - -const errorMessage = 'Network requests are not allowed in tests'; - -const origHttpAgent = http.globalAgent; -const origHttpsAgent = https.globalAgent; -const origFetch = global.fetch; -const origXMLHttpRequest = global.XMLHttpRequest; - -http.globalAgent = new http.Agent({ - lookup() { - throw new Error(errorMessage); - }, -}); - -https.globalAgent = new https.Agent({ - lookup() { - throw new Error(errorMessage); - }, -}); - -const BLOCKING_FETCH_SYMBOL = Symbol.for( - 'backstage.jestRejectNetworkRequests.blockingFetch', -); - -if (global.fetch) { - const blockingFetch = async (input, init) => { - // If global.fetch still has our marker, block the request - if (global.fetch[BLOCKING_FETCH_SYMBOL]) { - throw new Error(errorMessage); - } - // MSW (or something else) wrapped us - pass through - return origFetch(input, init); - }; - blockingFetch[BLOCKING_FETCH_SYMBOL] = true; - global.fetch = blockingFetch; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestRejectNetworkRequests'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest network request rejection. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -if (global.XMLHttpRequest) { - global.XMLHttpRequest = class { - constructor() { - throw new Error(errorMessage); - } - }; -} - -// Reset overrides after each suite to make sure we don't pollute the test environment -afterAll(() => { - http.globalAgent = origHttpAgent; - https.globalAgent = origHttpsAgent; - global.fetch = origFetch; - global.XMLHttpRequest = origXMLHttpRequest; -}); diff --git a/packages/cli/config/jestSucraseTransform.js b/packages/cli/config/jestSucraseTransform.js index 621b9ee297..fe20351c29 100644 --- a/packages/cli/config/jestSucraseTransform.js +++ b/packages/cli/config/jestSucraseTransform.js @@ -14,74 +14,14 @@ * limitations under the License. */ -const { createHash } = require('node:crypto'); -const { transform } = require('sucrase'); -const sucrasePkg = require('sucrase/package.json'); - -const ESM_REGEX = /\b(?:import|export)\b/; - -function createTransformer(config) { - const process = (source, filePath) => { - let transforms; - - if (filePath.endsWith('.esm.js')) { - transforms = ['imports']; - } else if (filePath.endsWith('.js')) { - // This is a very rough filter to avoid transforming things that we quickly - // can be sure are definitely not ESM modules. - if (ESM_REGEX.test(source)) { - transforms = ['imports', 'jsx']; // JSX within .js is currently allowed - } - } else if (filePath.endsWith('.jsx')) { - transforms = ['jsx', 'imports']; - } else if (filePath.endsWith('.ts')) { - transforms = ['typescript', 'imports']; - } else if (filePath.endsWith('.tsx')) { - transforms = ['typescript', 'jsx', 'imports']; - } - - // Only apply the jest transform to the test files themselves - if (transforms && filePath.includes('.test.')) { - transforms.push('jest'); - } - - if (transforms) { - const { code, sourceMap: map } = transform(source, { - transforms, - filePath, - disableESTransforms: true, - sourceMapOptions: { - compiledFilename: filePath, - }, - }); - if (config.enableSourceMaps) { - const b64 = Buffer.from(JSON.stringify(map), 'utf8').toString('base64'); - const suffix = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`; - // Include both inline and object source maps, as inline source maps are - // needed for support of some editor integrations. - return { code: `${code}\n${suffix}`, map }; - } - // We only return the `map` result if source maps are enabled, as they - // have a negative impact on the coverage accuracy. - return { code }; - } - - return { code: source }; - }; - - const getCacheKey = sourceText => { - return createHash('sha256') - .update(sourceText) - .update(Buffer.alloc(1)) - .update(sucrasePkg.version) - .update(Buffer.alloc(1)) - .update(JSON.stringify(config)) - .update(Buffer.alloc(1)) - .update('1') // increment whenever the transform logic in this file changes - .digest('hex'); - }; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestSucraseTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest Sucrase transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/jestSwcTransform.js b/packages/cli/config/jestSwcTransform.js index 4183349554..4aa6776c22 100644 --- a/packages/cli/config/jestSwcTransform.js +++ b/packages/cli/config/jestSwcTransform.js @@ -13,32 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -const { createTransformer: createSwcTransformer } = require('@swc/jest'); -const ESM_REGEX = /\b(?:import|export)\b/; - -function createTransformer(config) { - const swcTransformer = createSwcTransformer({ - inputSourceMap: false, - ...config, - }); - const process = (source, filePath, jestOptions) => { - // Skip transformation of .js files without ESM syntax, we never transform from CJS to ESM - if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { - return { code: source }; - } - - // Skip transformation of .mjs files, they should only be used if ESM support is available - if (jestOptions.supportsStaticESM && filePath.endsWith('.mjs')) { - return { code: source }; - } - - return swcTransformer.process(source, filePath, jestOptions); - }; - - const getCacheKey = swcTransformer.getCacheKey; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestSwcTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest SWC transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/jestYamlTransform.js b/packages/cli/config/jestYamlTransform.js index 7a05274add..85b889498f 100644 --- a/packages/cli/config/jestYamlTransform.js +++ b/packages/cli/config/jestYamlTransform.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * 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. @@ -14,27 +14,14 @@ * limitations under the License. */ -const yaml = require('yaml'); -const crypto = require('node:crypto'); - -function createTransformer(config) { - const process = source => { - const json = JSON.stringify(yaml.parse(source), null, 2); - return { code: `module.exports = ${json}`, map: null }; - }; - - const getCacheKey = sourceText => { - return crypto - .createHash('sha256') - .update(sourceText) - .update(Buffer.alloc(1)) - .update(JSON.stringify(config)) - .update(Buffer.alloc(1)) - .update('1') // increment whenever the transform logic in this file changes - .digest('hex'); - }; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestYamlTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest YAML transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/nodeTransform.cjs b/packages/cli/config/nodeTransform.cjs index 15bcdba9bc..83815ebea9 100644 --- a/packages/cli/config/nodeTransform.cjs +++ b/packages/cli/config/nodeTransform.cjs @@ -14,74 +14,14 @@ * limitations under the License. */ -const { pathToFileURL } = require('node:url'); -const { transformSync } = require('@swc/core'); -const { addHook } = require('pirates'); -const { Module } = require('node:module'); - -// This hooks into module resolution and overrides imports of packages that -// exist in the linked workspace to instead be resolved from the linked workspace. -if (process.env.BACKSTAGE_CLI_LINKED_WORKSPACE) { - const { join: joinPath } = require('node:path'); - const { getPackagesSync } = require('@manypkg/get-packages'); - const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( - process.env.BACKSTAGE_CLI_LINKED_WORKSPACE, - ); - - // Matches all packages in the linked workspaces, as well as sub-path exports from them - const replacementRegex = new RegExp( - `^(?:${linkedPackages - .map(pkg => pkg.packageJson.name) - .join('|')})(?:/.*)?$`, - ); - - const origLoad = Module._load; - Module._load = function requireHook(request, parent) { - if (!replacementRegex.test(request)) { - return origLoad.call(this, request, parent); - } - - // The package import that we're overriding will always existing in the root - // node_modules of the linked workspace, so it's enough to override the - // parent paths with that single entry - return origLoad.call(this, request, { - ...parent, - paths: [joinPath(linkedRoot.dir, 'node_modules')], - }); - }; +try { + require('@backstage/cli-module-build/config/nodeTransform.cjs'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-build is required to use the node transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -addHook( - (code, filename) => { - const transformed = transformSync(code, { - filename, - sourceMaps: 'inline', - module: { - type: 'commonjs', - ignoreDynamic: true, - }, - jsc: { - target: 'es2023', - parser: { - syntax: 'typescript', - }, - }, - }); - process.send?.({ type: 'watch', path: filename }); - return transformed.code; - }, - { extensions: ['.ts', '.cts'], ignoreNodeModules: true }, -); - -addHook( - (code, filename) => { - process.send?.({ type: 'watch', path: filename }); - return code; - }, - { extensions: ['.js', '.cjs'], ignoreNodeModules: true }, -); - -// Register module hooks, used by "type": "module" in package.json, .mjs and -// .mts files, as well as dynamic import(...)s, although dynamic imports will be -// handled be the CommonJS hooks in this file if what it points to is CommonJS. -Module.register('./nodeTransformHooks.mjs', pathToFileURL(__filename)); diff --git a/packages/cli/config/nodeTransformHooks.mjs b/packages/cli/config/nodeTransformHooks.mjs index 592867e57a..9fe9b327b5 100644 --- a/packages/cli/config/nodeTransformHooks.mjs +++ b/packages/cli/config/nodeTransformHooks.mjs @@ -14,281 +14,7 @@ * limitations under the License. */ -import { dirname, extname, resolve as resolvePath } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { transformFile } from '@swc/core'; -import { isBuiltin } from 'node:module'; -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; - -// @ts-check - -// No explicit file extension, no type in package.json -const DEFAULT_MODULE_FORMAT = 'commonjs'; - -// Source file extensions to look for when using bundle resolution strategy -const SRC_EXTS = ['.ts', '.js']; -const TS_EXTS = ['.ts', '.mts', '.cts']; -const moduleTypeTable = { - '.mjs': 'module', - '.mts': 'module', - '.cjs': 'commonjs', - '.cts': 'commonjs', - '.ts': undefined, - '.js': undefined, -}; - -/** @type {import('module').ResolveHook} */ -export async function resolve(specifier, context, nextResolve) { - // Built-in modules are handled by the default resolver - if (isBuiltin(specifier)) { - return nextResolve(specifier, context); - } - - const ext = extname(specifier); - - // Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below. - if (ext === '.json' && !context.importAttributes?.type) { - const jsonResult = await nextResolve(specifier, context); - return { - ...jsonResult, - format: 'commonjs', - importAttributes: { type: 'json' }, - }; - } - - // Anything else with an explicit extension is handled by the default - // resolver, except that we help determine the module type where needed. - if (ext !== '') { - return withDetectedModuleType(await nextResolve(specifier, context)); - } - - // Other external modules are handled by the default resolver, but again we - // help determine the module type where needed. - if (!specifier.startsWith('.')) { - return withDetectedModuleType(await nextResolve(specifier, context)); - } - - // The rest of this function handles the case of resolving imports that do not - // specify any extension and might point to a directory with an `index.*` - // file. We resolve those using the same logic as most JS bundlers would, with - // the addition of checking if there's an explicit module format listed in the - // closest `package.json` file. - // - // We use a bundle resolution strategy in order to keep code consistent across - // Backstage codebases that contains code both for Web and Node.js, and to - // support packages with common code that can be used in both environments. - try { - // This is expected to throw, but in the event that this module specifier is - // supported we prefer to use the default resolver. - return await nextResolve(specifier, context); - } catch (error) { - if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { - const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`; - const resolved = await resolveWithoutExt(spec, context, nextResolve); - if (resolved) { - return withDetectedModuleType(resolved); - } - } else if (error.code === 'ERR_MODULE_NOT_FOUND') { - const resolved = await resolveWithoutExt(specifier, context, nextResolve); - if (resolved) { - return withDetectedModuleType(resolved); - } - } - - // Unexpected error or no resolution found - throw error; - } -} - -/** - * Populates the `format` field in the resolved object based on the closest `package.json` file. - * - * @param {import('module').ResolveFnOutput} resolved - * @returns {Promise} - */ -async function withDetectedModuleType(resolved) { - // Already has an explicit format - if (resolved.format) { - return resolved; - } - // Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default. - if (resolved.format === null) { - return { ...resolved, format: DEFAULT_MODULE_FORMAT }; - } - - const ext = extname(resolved.url); - - const explicitFormat = moduleTypeTable[ext]; - if (explicitFormat) { - return { - ...resolved, - format: explicitFormat, - }; - } - - // Under normal circumstances .js files should reliably have a format and so - // we should only reach this point for .ts files. However, if additional - // custom loaders are being used the format may not be detected for .js files - // either. As such we don't restrict the file format at this point. - - // TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring - const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url)); - if (!packageJsonPath) { - return resolved; - } - - const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); - return { - ...resolved, - format: packageJson.type ?? DEFAULT_MODULE_FORMAT, - }; -} - -/** - * Find the closest package.json file from the given path. - * - * TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable. - * @param {string} startPath - * @returns {Promise} - */ -async function findPackageJSON(startPath) { - let path = startPath; - - // Some confidence check to avoid infinite loop - for (let i = 0; i < 1000; i++) { - const packagePath = resolvePath(path, 'package.json'); - if (existsSync(packagePath)) { - return packagePath; - } - - const newPath = dirname(path); - if (newPath === path) { - return undefined; - } - path = newPath; - } - - throw new Error( - `Iteration limit reached when searching for package.json at ${startPath}`, - ); -} - -/** @type {import('module').ResolveHook} */ -async function resolveWithoutExt(specifier, context, nextResolve) { - for (const tryExt of SRC_EXTS) { - try { - const resolved = await nextResolve(specifier + tryExt, { - ...context, - format: 'commonjs', - }); - return { - ...resolved, - format: moduleTypeTable[tryExt] ?? resolved.format, - }; - } catch { - /* ignore */ - } - } - return undefined; -} - -/** @type {import('module').LoadHook} */ -export async function load(url, context, nextLoad) { - // Non-file URLs are handled by the default loader - if (!url.startsWith('file://')) { - return nextLoad(url, context); - } - - // JSON files loaded as CommonJS are handled by this custom loader, because - // the default one doesn't work. For JSON loading to work we'd need the - // synchronous hooks that aren't supported yet, or avoid using the CommonJS - // compatibility. - if ( - context.format === 'commonjs' && - context.importAttributes?.type === 'json' - ) { - try { - // TODO(Rugvip): Make sure this is valid JSON - const content = await readFile(fileURLToPath(url), 'utf8'); - return { - source: `module.exports = (${content})`, - format: 'commonjs', - shortCircuit: true, - }; - } catch { - // Let the default loader generate the error - return nextLoad(url, context); - } - } - - const ext = extname(url); - - // Non-TS files are handled by the default loader - if (!TS_EXTS.includes(ext)) { - return nextLoad(url, context); - } - - const format = context.format ?? DEFAULT_MODULE_FORMAT; - - // We have two choices at this point, we can either transform CommonJS files - // and return the transformed source code, or let the default loader handle - // them. If we transform them ourselves we will enter CommonJS compatibility - // mode in the new module system in Node.js, this effectively means all - // CommonJS loaded via `require` calls from this point will all be treated as - // if it was loaded via `import` calls from modules. - // - // The CommonJS compatibility layer will try to identify named exports and - // make them available directly, which is convenient as it avoids things like - // `import(...).then(m => m.default.foo)`, allowing you to instead write - // `import(...).then(m => m.foo)`. The compatibility layer doesn't always work - // all that well though, and can lead to module loading issues in many cases, - // especially for older code. - - // This `if` block opts-out of using CommonJS compatibility mode by default, - // and instead leaves it to our existing loader to transform CommonJS. We do - // however use compatibility mode for the more explicit .cts file extension, - // allows for a way to opt-in to the new behavior. - // - // TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead - if (format === 'commonjs' && ext !== '.cts') { - return nextLoad(url, { ...context, format }); - } - - // If the Node.js version we're running supports TypeScript, i.e. type - // stripping, we hand over to the default loader. This is done for all cases - // except if we're loading a .ts file that's been resolved to CommonJS format. - // This is because these files aren't actually CommonJS in the Backstage build - // system, and need to be transformed to CommonJS. - if ( - format === 'module-typescript' || - (format === 'module-commonjs' && ext !== '.ts') - ) { - return nextLoad(url, { ...context, format }); - } - - const transformed = await transformFile(fileURLToPath(url), { - sourceMaps: 'inline', - module: { - type: format === 'module' ? 'es6' : 'commonjs', - ignoreDynamic: true, - - // This helps the Node.js CommonJS compat layer identify named exports. - exportInteropAnnotation: true, - }, - jsc: { - target: 'es2023', - parser: { - syntax: 'typescript', - }, - }, - }); - - return { - ...context, - shortCircuit: true, - source: transformed.code, - format, - responseURL: url, - }; -} +export { + resolve, + load, +} from '@backstage/cli-module-build/config/nodeTransformHooks.mjs'; diff --git a/packages/cli/config/webpack-public-path.js b/packages/cli/config/webpack-public-path.js index 3e14e96eb9..d1c36222c9 100644 --- a/packages/cli/config/webpack-public-path.js +++ b/packages/cli/config/webpack-public-path.js @@ -14,18 +14,14 @@ * limitations under the License. */ -// This script is used to pick up and set the public path of the Webpack bundle -// at runtime. The meta tag is injected by the app build, but only present in -// the `index.html.tmpl` file. The runtime value of the meta tag is populated by -// the app backend, when it templates the final `index.html` file. -// -// This is needed for additional chunks to use the correct public path, and it -// is not possible to set the `__webpack_public_path__` variable outside of the -// build itself. The Webpack output also does not read any tags or -// similar, this seems to be the only way to dynamically configure the public -// path at runtime. -const el = document.querySelector('meta[name="backstage-public-path"]'); -const path = el?.getAttribute('content'); -if (path) { - __webpack_public_path__ = path; +try { + require('@backstage/cli-module-build/config/webpack-public-path'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-build is required to use the webpack public path configuration. ' + + 'Please install it as a dependency.', + ); + } + throw e; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 600f150654..4ba287a9ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,6 +49,8 @@ "dependencies": { "@backstage/cli-common": "workspace:^", "@backstage/cli-defaults": "workspace:^", + "@backstage/cli-module-build": "workspace:^", + "@backstage/cli-module-test-jest": "workspace:^", "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/eslint-plugin": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 643fa1d573..d87b8e32d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2869,6 +2869,7 @@ __metadata: "@rspack/core": "npm:^1.4.11" "@rspack/dev-server": "npm:^1.1.4" "@rspack/plugin-react-refresh": "npm:^1.4.3" + "@swc/core": "npm:^1.15.6" "@types/fs-extra": "npm:^11.0.0" "@types/lodash": "npm:^4.14.151" "@types/npm-packlist": "npm:^3.0.0" @@ -2893,6 +2894,7 @@ __metadata: node-stdlib-browser: "npm:^1.3.1" npm-packlist: "npm:^5.0.0" p-queue: "npm:^6.6.2" + pirates: "npm:^4.0.6" postcss: "npm:^8.1.0" postcss-import: "npm:^16.1.0" process: "npm:^0.11.10" @@ -3084,10 +3086,27 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" "@backstage/cli-node": "workspace:^" + "@swc/core": "npm:^1.15.6" + "@swc/jest": "npm:^0.2.39" cleye: "npm:^2.3.0" + cross-fetch: "npm:^4.0.0" + fs-extra: "npm:^11.2.0" + glob: "npm:^7.1.7" + jest-css-modules: "npm:^2.1.0" + sucrase: "npm:^3.20.2" yargs: "npm:^16.2.0" peerDependencies: + "@jest/environment-jsdom-abstract": ^30.0.0 jest-cli: ^29.0.0 || ^30.0.0 + jest-environment-jsdom: "*" + jsdom: ^27.1.0 + peerDependenciesMeta: + "@jest/environment-jsdom-abstract": + optional: true + jest-environment-jsdom: + optional: true + jsdom: + optional: true bin: cli-module-test-jest: bin/cli-module-test-jest languageName: unknown @@ -3141,6 +3160,8 @@ __metadata: "@backstage/catalog-client": "workspace:^" "@backstage/cli-common": "workspace:^" "@backstage/cli-defaults": "workspace:^" + "@backstage/cli-module-build": "workspace:^" + "@backstage/cli-module-test-jest": "workspace:^" "@backstage/cli-node": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^"