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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-15 22:11:43 +01:00
parent 00adaa9902
commit 55f6eb8c64
37 changed files with 1476 additions and 1075 deletions
+3 -1
View File
@@ -1 +1,3 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
ignorePatterns: ['config/**', 'templates/**'],
});
@@ -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));
@@ -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<import('module').ResolveFnOutput>}
*/
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<string | undefined>}
*/
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,
};
}
@@ -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 <base> 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;
}
+3
View File
@@ -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",
@@ -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,
],
@@ -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()
];
@@ -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' },
+3 -1
View File
@@ -1 +1,3 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
ignorePatterns: ['config/**'],
});
@@ -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 };
@@ -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;
@@ -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<keyof import('@jest/types').Config.ProjectConfig>}
*/
const projectConfigKeys = [
'automock',
'cache',
'cacheDirectory',
'clearMocks',
'collectCoverageFrom',
'coverageDirectory',
'coveragePathIgnorePatterns',
'cwd',
'dependencyExtractor',
'detectLeaks',
'detectOpenHandles',
'displayName',
'errorOnDeprecated',
'extensionsToTreatAsEsm',
'fakeTimers',
'filter',
'forceCoverageMatch',
'globalSetup',
'globalTeardown',
'globals',
'haste',
'id',
'injectGlobals',
'moduleDirectories',
'moduleFileExtensions',
'moduleNameMapper',
'modulePathIgnorePatterns',
'modulePaths',
'openHandlesTimeout',
'preset',
'prettierPath',
'resetMocks',
'resetModules',
'resolver',
'restoreMocks',
'rootDir',
'roots',
'runner',
'runtime',
'sandboxInjectedGlobals',
'setupFiles',
'setupFilesAfterEnv',
'skipFilter',
'skipNodeResolution',
'slowTestThreshold',
'snapshotResolver',
'snapshotSerializers',
'snapshotFormat',
'testEnvironment',
'testEnvironmentOptions',
'testMatch',
'testLocationInResults',
'testPathIgnorePatterns',
'testRegex',
'testRunner',
'transform',
'transformIgnorePatterns',
'watchPathIgnorePatterns',
'unmockedModulePathPatterns',
'workerIdleMemoryLimit',
];
const transformIgnorePattern = [
'@material-ui',
'ajv',
'core-js',
'jest-.*',
'jsdom',
'knex',
'react',
'react-dom',
'highlight\\.js',
'prismjs',
'json-schema',
'react-use/lib',
'typescript',
].join('|');
// Provides additional config that's based on the role of the target package
function getRoleConfig(role, pkgJson) {
// Only Node.js package roles support native ESM modules, frontend and common
// packages are always transpiled to CommonJS.
const moduleOpts = NODE_ROLES.includes(role)
? {
module: {
ignoreDynamic: true,
exportInteropAnnotation: true,
},
}
: undefined;
const transform = {
'\\.(mjs|cjs|js)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'ecmascript',
},
},
},
],
'\\.jsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(mts|cts|ts)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'typescript',
},
},
},
],
'\\.tsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$':
require.resolve('./jestFileTransform.js'),
'\\.(yaml)$': require.resolve('./jestYamlTransform'),
};
if (FRONTEND_ROLES.includes(role)) {
return {
testEnvironment: 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(`<rootDir>/setupTests.${ext}`);
break;
}
}
const config = Object.assign(options, pkgJson.jest);
// The config id is a cache key that lets us share the jest cache across projects.
// If no explicit id was configured, generated one based on the configuration.
if (!config.id) {
const configHash = crypto
.createHash('sha256')
.update(version)
.update(Buffer.alloc(1))
.update(JSON.stringify(config.transform).replaceAll(paths.targetRoot, ''))
.digest('hex');
config.id = `backstage_cli_${configHash}`;
}
return config;
}
// This loads the root jest config, which in turn will either refer to a single
// configuration for the current package, or a collection of configurations for
// the target workspace packages
async function getRootConfig() {
const rootPkgJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
const baseCoverageConfig = {
coverageDirectory: paths.resolveTarget('coverage'),
coverageProvider: envOptions.oldTests ? 'v8' : 'babel',
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'],
};
const { rejectFrontendNetworkRequests, ...rootOptions } =
rootPkgJson.jest ?? {};
const extraRootOptions = {
rejectFrontendNetworkRequests,
};
const 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();
@@ -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;
};
@@ -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);
}
};
@@ -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};` };
},
};
@@ -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;
});
@@ -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 };
@@ -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 };
@@ -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 };
+24 -2
View File
@@ -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"
}
@@ -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')) {
@@ -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')) {
+7 -29
View File
@@ -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 };
@@ -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;
+7 -402
View File
@@ -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<keyof import('@jest/types').Config.ProjectConfig>}
*/
const projectConfigKeys = [
'automock',
'cache',
'cacheDirectory',
'clearMocks',
'collectCoverageFrom',
'coverageDirectory',
'coveragePathIgnorePatterns',
'cwd',
'dependencyExtractor',
'detectLeaks',
'detectOpenHandles',
'displayName',
'errorOnDeprecated',
'extensionsToTreatAsEsm',
'fakeTimers',
'filter',
'forceCoverageMatch',
'globalSetup',
'globalTeardown',
'globals',
'haste',
'id',
'injectGlobals',
'moduleDirectories',
'moduleFileExtensions',
'moduleNameMapper',
'modulePathIgnorePatterns',
'modulePaths',
'openHandlesTimeout',
'preset',
'prettierPath',
'resetMocks',
'resetModules',
'resolver',
'restoreMocks',
'rootDir',
'roots',
'runner',
'runtime',
'sandboxInjectedGlobals',
'setupFiles',
'setupFilesAfterEnv',
'skipFilter',
'skipNodeResolution',
'slowTestThreshold',
'snapshotResolver',
'snapshotSerializers',
'snapshotFormat',
'testEnvironment',
'testEnvironmentOptions',
'testMatch',
'testLocationInResults',
'testPathIgnorePatterns',
'testRegex',
'testRunner',
'transform',
'transformIgnorePatterns',
'watchPathIgnorePatterns',
'unmockedModulePathPatterns',
'workerIdleMemoryLimit',
];
const transformIgnorePattern = [
'@material-ui',
'ajv',
'core-js',
'jest-.*',
'jsdom',
'knex',
'react',
'react-dom',
'highlight\\.js',
'prismjs',
'json-schema',
'react-use/lib',
'typescript',
].join('|');
// Provides additional config that's based on the role of the target package
function getRoleConfig(role, pkgJson) {
// Only Node.js package roles support native ESM modules, frontend and common
// packages are always transpiled to CommonJS.
const moduleOpts = NODE_ROLES.includes(role)
? {
module: {
ignoreDynamic: true,
exportInteropAnnotation: true,
},
}
: undefined;
const transform = {
'\\.(mjs|cjs|js)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'ecmascript',
},
},
},
],
'\\.jsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(mts|cts|ts)$': [
require.resolve('./jestSwcTransform'),
{
...moduleOpts,
jsc: {
parser: {
syntax: 'typescript',
},
},
},
],
'\\.tsx$': [
require.resolve('./jestSwcTransform'),
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$':
require.resolve('./jestFileTransform.js'),
'\\.(yaml)$': require.resolve('./jestYamlTransform'),
};
if (FRONTEND_ROLES.includes(role)) {
return {
testEnvironment: 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(`<rootDir>/setupTests.${ext}`);
break;
}
}
const config = Object.assign(options, pkgJson.jest);
// The config id is a cache key that lets us share the jest cache across projects.
// If no explicit id was configured, generated one based on the configuration.
if (!config.id) {
const configHash = crypto
.createHash('sha256')
.update(version)
.update(Buffer.alloc(1))
.update(JSON.stringify(config.transform).replaceAll(paths.targetRoot, ''))
.digest('hex');
config.id = `backstage_cli_${configHash}`;
}
return config;
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();
@@ -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;
}
+11 -19
View File
@@ -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;
}
+11 -28
View File
@@ -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;
}
@@ -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;
});
+10 -70
View File
@@ -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 };
+10 -27
View File
@@ -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 };
+11 -24
View File
@@ -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 };
+10 -70
View File
@@ -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));
+4 -278
View File
@@ -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<import('module').ResolveFnOutput>}
*/
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<string | undefined>}
*/
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';
+10 -14
View File
@@ -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 <base> 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;
}
+2
View File
@@ -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:^",
+21
View File
@@ -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:^"