chore: make jest a peer dependency with v29/v30 support

Move jest from dependencies to peer dependencies, allowing users to
choose between Jest 29 and Jest 30.

The CLI now detects the Jest version at runtime and uses the
appropriate environment:
- Jest 29: Uses standard jest-environment-jsdom
- Jest 30: Uses a custom environment based on @jest/environment-jsdom-abstract
  with fixes for Web API globals (fetch, streams, Error, etc.)

The cross-fetch polyfill is only injected for Jest 29, as with Jest 30+
our patched Jest environment is used. The network request blocker is made
MSW-compatible by checking if fetch was wrapped before blocking.

Jest 30 (with jsdom v27) fixes `Could not parse CSS stylesheet`
warnings/errors when testing components from @backstage/ui or other
packages using CSS `@layer` declarations.

New peer dependencies (install based on your Jest version):
- jest (required, ^29 or ^30)
- Jest 29 requires: jest-environment-jsdom
- Jest 30 requires: @jest/environment-jsdom-abstract, jsdom

Production code changes for jsdom 27 testability:
- AppIdentityProxy: extract navigateToUrl method for spying
- LiveReloadAddon: export utils.reloadPage for spying
- collect.ts: export internal.resolvePackagePath for mocking

MockFetchApi: evaluate global.fetch at call time instead of construction
time, allowing MSW to patch fetch after MockFetchApi is constructed.

Test adaptations for jsdom 27:
- Use RGB values instead of named colors in CSS assertions
- Update error format expectations (hyphenated type names, SyntaxError
  instead of FetchError for JSON parse errors)
- Simplify URL error assertions for cross-version compatibility
- Fix accessible name whitespace handling for external links
- Use history.replaceState for location mocking (non-configurable)
- Use fireEvent.blur for contentEditable elements
- Move async assertions inside waitFor for race conditions
- Remove Blob.prototype.text polyfill (now native)
- Remove test case using credentials in plugin:// URLs

Test adaptations for Jest 30:
- Replace `expect.objectContaining([...])` with direct array equality
- Replace `expect.objectContaining({ length: N })` with
  `expect.any(Array)` + separate `toHaveLength()` assertions
- Use child process for native Node.js module resolution in
  collect.test.ts to work around Jest 30's resolver behavior
- Update snapshot headers for new Jest format

Also removes the jest-haste-map patch which is no longer needed.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-12-01 09:33:31 +01:00
parent 6dd4fdfae0
commit cd0b8a11a3
47 changed files with 1764 additions and 1227 deletions
+49
View File
@@ -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;
+9 -2
View File
@@ -20,6 +20,10 @@ const crypto = require('crypto');
const glob = require('util').promisify(require('glob'));
const { version } = require('../package.json');
const paths = require('@backstage/cli-common').findPaths(process.cwd());
const {
getJestEnvironment,
getJestMajorVersion,
} = require('./getJestEnvironment');
const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs'];
@@ -211,7 +215,7 @@ function getRoleConfig(role, pkgJson) {
};
if (FRONTEND_ROLES.includes(role)) {
return {
testEnvironment: require.resolve('jest-environment-jsdom'),
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
@@ -279,7 +283,10 @@ async function getProjectConfig(targetPath, extraConfig, extraOptions) {
);
}
if (options.testEnvironment === require.resolve('jest-environment-jsdom')) {
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'));
}
@@ -14,6 +14,8 @@
* 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 {
@@ -22,7 +22,7 @@ const errorMessage = 'Network requests are not allowed in tests';
const origHttpAgent = http.globalAgent;
const origHttpsAgent = https.globalAgent;
const origFetch = global.fetch;
const origXMLHttpRequest = global.fetch;
const origXMLHttpRequest = global.XMLHttpRequest;
http.globalAgent = new http.Agent({
lookup() {
@@ -36,10 +36,21 @@ https.globalAgent = new https.Agent({
},
});
const BLOCKING_FETCH_SYMBOL = Symbol.for(
'backstage.jestRejectNetworkRequests.blockingFetch',
);
if (global.fetch) {
global.fetch = async () => {
throw new Error(errorMessage);
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) {