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
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/cli': minor
---
**BREAKING**: `jest` is now a peer dependency. If you run tests using Backstage CLI, you must add Jest and its environment dependencies as `devDependencies` in your project.
You can choose to install either Jest 29 or Jest 30:
- **Jest 29**: Install `jest@^29` and `jest-environment-jsdom@^29`. No migration needed, but you may see `Could not parse CSS stylesheet` warnings/errors when testing components from `@backstage/ui` or other packages using CSS `@layer` declarations.
- **Jest 30**: Install `jest@^30`, `@jest/environment-jsdom-abstract@^30`, and `jsdom@^27`. Fixes the stylesheet parsing warnings/errors, but requires migration steps.
See the [Jest 30 migration guide](https://backstage.io/docs/tutorials/jest30-migration) for detailed migration instructions.
@@ -268,6 +268,7 @@ maintainer's
maintainership
makefile
Matomo
matchers
md
memcache
memoization
+76
View File
@@ -0,0 +1,76 @@
---
id: jest30-migration
title: Migrating to Jest 30
description: A guide to migrating your project to Jest 30 and JSDOM 27
---
Starting with a recent version of `@backstage/cli`, `jest` is a peer dependency. If you run tests using Backstage CLI, you must add Jest and its environment dependencies as `devDependencies` in your project.
You can choose to install either Jest 29 or Jest 30:
- **Jest 29**: Install `jest@^29` and `jest-environment-jsdom@^29`. No migration needed, but you may see `Could not parse CSS stylesheet` warnings/errors when testing components from `@backstage/ui` or other packages using CSS `@layer` declarations.
- **Jest 30**: Install `jest@^30`, `@jest/environment-jsdom-abstract@^30`, and `jsdom@^27`. Fixes the stylesheet parsing warnings/errors, but requires the migration steps below.
## Migration Guide
The examples below are issues we encountered while migrating the Backstage repository. For a complete list of breaking changes, see the official documentation:
- [Jest 30 upgrade guide](https://jestjs.io/docs/upgrading-to-jest30)
- [JSDOM changelog](https://github.com/jsdom/jsdom/releases)
### Jest 30
**Asymmetric matchers with arrays**: `expect.objectContaining()` no longer works with arrays.
```diff
- expect(result).toEqual(expect.objectContaining([{ id: '123' }]));
+ expect(result).toEqual([{ id: '123' }]);
// or
+ expect(result).toEqual(expect.arrayContaining([{ id: '123' }]));
```
**Array length assertions**: `expect.objectContaining({ length: N })` no longer works.
```diff
- expect(fn).toHaveBeenCalledWith(expect.objectContaining({ length: 2 }));
+ expect(fn).toHaveBeenCalledWith(expect.any(Array));
+ expect(fn.mock.calls[0][0]).toHaveLength(2);
```
**Deprecated matcher aliases removed**: Replace with canonical names.
```diff
- expect(fn).toBeCalled();
+ expect(fn).toHaveBeenCalled();
```
**Snapshots**: Regenerate snapshots as the header format has changed.
```bash
yarn test --no-watch -u
```
### JSDOM 27
**window.location is non-configurable**: You can no longer mock location via `Object.defineProperty`.
```diff
- Object.defineProperty(window, 'location', { value: { href: '' } });
+ // Option 1: Use history API
+ history.replaceState({}, '', '/new-path');
+ // Option 2: Spy on navigation methods
+ const spy = jest.spyOn(component, 'navigate');
```
**CSS color values**: Colors may be returned as RGB instead of named colors.
```diff
- expect(element.style.color).toBe('red');
+ expect(element.style.color).toBe('rgb(255, 0, 0)');
```
**Error format changes**: Error messages and stack traces may have different formatting.
#### If you run into `Cannot read properties of null (reading 'constructor')`
Certain Backstage UI-components (e.g. Button) have a combination of CSS that triggers this error in tests. The solution is to make sure you have at least v0.9.25 of `@acemir/cssom`.
-1
View File
@@ -117,7 +117,6 @@
"csstype@npm:^3.0.2": "3.0.9",
"csstype@npm:^3.1.2": "3.0.9",
"csstype@npm:^3.1.3": "3.0.9",
"jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch",
"recast@npm:0.23.9>ast-types": "patch:ast-types@npm%3A0.16.1#./.yarn/patches/ast-types-npm-0.16.1-43c4ac4b0d.patch"
},
"dependencies": {
+4 -5
View File
@@ -324,7 +324,7 @@ Options:
### `backstage-cli package test`
```
Usage: backstage-cli [--config=<pathToConfigFile>] [TestPathPattern]
Usage: backstage-cli [--config=<pathToConfigFile>] [TestPathPatterns]
Options:
--all
@@ -349,7 +349,6 @@ Options:
--debug
--detectLeaks
--detectOpenHandles
--env
--errorOnDeprecated
--filter
--findRelatedTests
@@ -359,7 +358,6 @@ Options:
--globals
--haste
--ignoreProjects
--init
--injectGlobals
--json
--lastCommit
@@ -400,13 +398,13 @@ Options:
--silent
--skipFilter
--snapshotSerializers
--testEnvironment
--testEnvironment, --env
--testEnvironmentOptions
--testFailureExitCode
--testLocationInResults
--testMatch
--testPathIgnorePatterns
--testPathPattern
--testPathPatterns
--testRegex
--testResultsProcessor
--testRunner
@@ -418,6 +416,7 @@ Options:
--useStderr
--verbose
--version
--waitForUnhandledRejections
--watch
--watchAll
--watchPathIgnorePatterns
+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) {
+17 -5
View File
@@ -73,7 +73,6 @@
"@swc/core": "^1.3.46",
"@swc/helpers": "^0.5.0",
"@swc/jest": "^0.2.22",
"@types/jest": "^29.5.11",
"@types/webpack-env": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.16.0",
@@ -109,11 +108,7 @@
"handlebars": "^4.7.3",
"html-webpack-plugin": "^5.6.3",
"inquirer": "^8.2.0",
"jest": "^29.7.0",
"jest-cli": "^29.7.0",
"jest-css-modules": "^2.1.0",
"jest-environment-jsdom": "^29.0.2",
"jest-runtime": "^29.0.2",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"minimatch": "^9.0.0",
@@ -167,6 +162,7 @@
"@backstage/plugin-scaffolder-node-test-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@backstage/theme": "workspace:^",
"@jest/environment-jsdom-abstract": "^30.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
"@types/cross-spawn": "^6.0.2",
"@types/ejs": "^3.1.3",
@@ -174,6 +170,7 @@
"@types/fs-extra": "^11.0.0",
"@types/http-proxy": "^1.17.4",
"@types/inquirer": "^8.1.3",
"@types/jest": "^30.0.0",
"@types/node": "^22.13.14",
"@types/npm-packlist": "^3.0.0",
"@types/recursive-readdir": "^2.2.0",
@@ -188,6 +185,8 @@
"esbuild-loader": "^4.0.0",
"eslint-webpack-plugin": "^4.2.0",
"fork-ts-checker-webpack-plugin": "^9.0.0",
"jest": "^30.2.0",
"jsdom": "^27.1.0",
"mini-css-extract-plugin": "^2.4.2",
"msw": "^1.0.0",
"nodemon": "^3.0.1",
@@ -196,17 +195,24 @@
"webpack-dev-server": "^5.0.0"
},
"peerDependencies": {
"@jest/environment-jsdom-abstract": "^30.0.0",
"@module-federation/enhanced": "^0.9.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
"esbuild-loader": "^4.0.0",
"eslint-webpack-plugin": "^4.2.0",
"fork-ts-checker-webpack-plugin": "^9.0.0",
"jest": "^29.0.0 || ^30.0.0",
"jest-environment-jsdom": "*",
"jsdom": "^27.1.0",
"mini-css-extract-plugin": "^2.4.2",
"terser-webpack-plugin": "^5.1.3",
"webpack": "~5.103.0",
"webpack-dev-server": "^5.0.0"
},
"peerDependenciesMeta": {
"@jest/environment-jsdom-abstract": {
"optional": true
},
"@module-federation/enhanced": {
"optional": true
},
@@ -222,6 +228,12 @@
"fork-ts-checker-webpack-plugin": {
"optional": true
},
"jest-environment-jsdom": {
"optional": true
},
"jsdom": {
"optional": true
},
"mini-css-extract-plugin": {
"optional": true
},
@@ -17,6 +17,8 @@
import os from 'os';
import crypto from 'node:crypto';
import yargs from 'yargs';
// 'jest-cli' is included with jest and should be kept in sync with the installed jest version
// eslint-disable-next-line @backstage/no-undeclared-imports
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
import { relative as relativePath } from 'path';
import { Command, OptionValues } from 'commander';
@@ -15,14 +15,9 @@
*/
import { createMockDirectory } from '@backstage/backend-test-utils';
import { collectConfigSchemas } from './collect';
import { collectConfigSchemas, internal } from './collect';
import path from 'path';
// cwd must be restored
const origDir = process.cwd();
afterAll(() => {
process.chdir(origDir);
});
import { execSync } from 'child_process';
const mockSchema = {
type: 'object',
@@ -37,6 +32,36 @@ const mockSchema = {
describe('collectConfigSchemas', () => {
const mockDir = createMockDirectory();
// Jest 30's module resolver doesn't find nested node_modules in mock directories.
// Use a child process for native Node.js resolution instead.
const originalResolvePackagePath = internal.resolvePackagePath;
beforeAll(() => {
internal.resolvePackagePath = (name, options) => {
const basePath = (options && options.paths?.[0]) ?? process.cwd();
const baseDir = basePath.endsWith('.json')
? path.dirname(basePath)
: basePath;
try {
return execSync('node', {
input: `console.log(require.resolve(${JSON.stringify(
name,
)}, { paths: [${JSON.stringify(baseDir)}] }))`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
const error = new Error(`Cannot find module '${name}'`);
(error as NodeJS.ErrnoException).code = 'MODULE_NOT_FOUND';
throw error;
}
};
});
afterAll(() => {
internal.resolvePackagePath = originalResolvePackagePath;
});
afterEach(() => {
mockDir.clear();
});
+13 -4
View File
@@ -37,6 +37,17 @@ const req =
? require
: __non_webpack_require__;
/**
* Exported for test mocking. Jest 30's module resolver has issues with
* nested node_modules, requiring tests to use an alternative resolution strategy.
* @internal
*/
export const internal = {
resolvePackagePath(name: string, options?: { paths: string[] }): string {
return req.resolve(name, options);
},
};
/**
* This collects all known config schemas across all dependencies of the app.
*/
@@ -62,11 +73,9 @@ export async function collectConfigSchemas(
const { name, parentPath } = item;
try {
pkgPath = req.resolve(
pkgPath = internal.resolvePackagePath(
`${name}/package.json`,
parentPath && {
paths: [parentPath],
},
parentPath ? { paths: [parentPath] } : undefined,
);
} catch {
// We can somewhat safely ignore packages that don't export package.json,
@@ -60,12 +60,6 @@ describe('PluginProtocolResolverFetchMiddleware', () => {
'https://real.com:8080/base',
'https://real.com:8080/base?c=d&e=f#g',
],
[
'plugin://username:password@x?c=d&e=f#g',
'x',
'https://real.com:8080/base',
'https://username:password@real.com:8080/base?c=d&e=f#g',
],
[
'plugin://x?c=d&e=f#g',
'x',
@@ -106,14 +100,14 @@ describe('PluginProtocolResolverFetchMiddleware', () => {
expect(inner.mock.calls[0][0]).toBe('https://elsewhere.com');
expect(inner.mock.calls[0][1].body).toBe('123');
await outer(
new Request('plugin://a', {
method: 'POST',
body: '123',
}),
);
const inputRequest = new Request('plugin://a', {
method: 'POST',
body: '123',
});
await outer(inputRequest);
expect(inner.mock.calls[1][0]).toBe('https://elsewhere.com');
expect(inner.mock.calls[1][1].body).toEqual(Buffer.from('123', 'utf8'));
expect(inner.mock.calls[1][1]).toBe(inputRequest);
});
});
@@ -83,12 +83,9 @@ describe('AppIdentityProxy', () => {
it('should navigate to target URL on sign out', async () => {
const proxy = new AppIdentityProxy();
proxy.setTarget(mockIdentityApi, { signOutTargetUrl: '/foo' });
Object.defineProperty(window, 'location', {
writable: true,
value: {},
});
const navigateSpy = jest.spyOn(proxy as any, 'navigateToUrl');
await proxy.signOut();
expect(window.location.href).toBe('/foo');
expect(navigateSpy).toHaveBeenCalledWith('/foo');
});
});
@@ -133,7 +133,11 @@ export class AppIdentityProxy implements IdentityApi {
await this.#cookieAuthSignOut?.();
window.location.href = this.signOutTargetUrl;
this.navigateToUrl(this.signOutTargetUrl);
}
private navigateToUrl(url: string): void {
window.location.href = url;
}
enableCookieAuth(ctx: {
@@ -117,16 +117,12 @@ describe('ApiProvider', () => {
}).toThrow(/^API context is not available/);
}).error,
).toEqual([
expect.objectContaining({
detail: new Error('API context is not available'),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error('API context is not available'),
type: 'unhandled exception',
}),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
expect.stringContaining('Error: API context is not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: API context is not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <MyHookConsumer> component',
),
]);
@@ -137,16 +133,12 @@ describe('ApiProvider', () => {
}).toThrow(/^API context is not available/);
}).error,
).toEqual([
expect.objectContaining({
detail: new Error('API context is not available'),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error('API context is not available'),
type: 'unhandled exception',
}),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
expect.stringContaining('Error: API context is not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: API context is not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <withApis(Component)> component',
),
]);
});
@@ -163,16 +155,16 @@ describe('ApiProvider', () => {
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.objectContaining({
detail: new Error('No implementation available for apiRef{x}'),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error('No implementation available for apiRef{x}'),
type: 'unhandled exception',
}),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <MyHookConsumer> component',
),
]);
@@ -187,16 +179,16 @@ describe('ApiProvider', () => {
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.objectContaining({
detail: new Error('No implementation available for apiRef{x}'),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error('No implementation available for apiRef{x}'),
type: 'unhandled exception',
}),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <withApis(Component)> component',
),
]);
});
@@ -670,14 +670,10 @@ describe('Integration Test', () => {
});
expect(errorLogs).toEqual([
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.stringContaining(`Error: ${expectedMessage}`),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(`Error: ${expectedMessage}`),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <Provider> component:',
),
@@ -714,14 +710,10 @@ describe('Integration Test', () => {
).rejects.toThrow(expectedMessage);
});
expect(errorLogs).toEqual([
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.stringContaining(`Error: ${expectedMessage}`),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(`Error: ${expectedMessage}`),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <Provider> component:',
),
@@ -262,13 +262,16 @@ describe('convertLegacyApp', () => {
],
});
// Increase timeout for async rendering of complex catalog entity pages
const findOptions = { timeout: 5000 };
// Overview
const renderOverviewTest = await renderTestApp({
features: [catalogOverride, ...converted],
initialRouteEntries: ['/catalog/default/test/x'],
});
await expect(
renderOverviewTest.findByText('overview content'),
renderOverviewTest.findByText('overview content', {}, findOptions),
).resolves.toBeInTheDocument();
renderOverviewTest.unmount();
@@ -277,7 +280,7 @@ describe('convertLegacyApp', () => {
initialRouteEntries: ['/catalog/default/other/x'],
});
await expect(
renderOverviewOther.findByText('other overview content'),
renderOverviewOther.findByText('other overview content', {}, findOptions),
).resolves.toBeInTheDocument();
renderOverviewOther.unmount();
@@ -287,7 +290,7 @@ describe('convertLegacyApp', () => {
initialRouteEntries: ['/catalog/default/test/x/foo'],
});
await expect(
renderFooTest.findByText('foo content'),
renderFooTest.findByText('foo content', {}, findOptions),
).resolves.toBeInTheDocument();
renderFooTest.unmount();
@@ -296,7 +299,7 @@ describe('convertLegacyApp', () => {
initialRouteEntries: ['/catalog/default/other/x/foo'],
});
await expect(
renderFooOther.findByText('other foo content'),
renderFooOther.findByText('other foo content', {}, findOptions),
).resolves.toBeInTheDocument();
renderFooOther.unmount();
@@ -306,7 +309,7 @@ describe('convertLegacyApp', () => {
initialRouteEntries: ['/catalog/default/test/x/bar'],
});
await expect(
renderBarTest.findByText('bar content'),
renderBarTest.findByText('bar content', {}, findOptions),
).resolves.toBeInTheDocument();
renderBarTest.unmount();
@@ -315,7 +318,7 @@ describe('convertLegacyApp', () => {
initialRouteEntries: ['/catalog/default/other/x/bar'],
});
await expect(
renderBarOther.findByText('other overview content'),
renderBarOther.findByText('other overview content', {}, findOptions),
).resolves.toBeInTheDocument(); // /bar does not exist, fall back to rendering overview
renderBarOther.unmount();
});
@@ -50,7 +50,7 @@ describe('<Link />', () => {
<Link to="http://something.external">External Link</Link>,
);
const externalLink = screen.getByRole('link', {
name: 'External Link , Opens in a new window',
name: 'External Link, Opens in a new window',
});
const externalLinkIcon = container.querySelector('svg');
expect(externalLink).not.toContainElement(externalLinkIcon);
@@ -62,6 +62,7 @@ describe('<Link />', () => {
External Link
</Link>,
);
// Note: when externalLinkIcon is present, the SVG adds whitespace to the accessible name
const externalLink = screen.getByRole('link', {
name: 'External Link , Opens in a new window',
});
@@ -47,18 +47,20 @@ describe('TabbedLayout', () => {
});
expect(error).toEqual([
expect.stringContaining(
'Error: Child of TabbedLayout must be an TabbedLayout.Route',
),
expect.objectContaining({
detail: new Error(
'Child of TabbedLayout must be an TabbedLayout.Route',
),
type: 'unhandled-exception',
}),
expect.stringContaining(
'Error: Child of TabbedLayout must be an TabbedLayout.Route',
),
expect.objectContaining({
detail: new Error(
'Child of TabbedLayout must be an TabbedLayout.Route',
),
type: 'unhandled-exception',
}),
expect.stringMatching(
/The above error occurred in the <TabbedLayout> component/,
expect.stringContaining(
'The above error occurred in the <TabbedLayout> component',
),
]);
});
@@ -68,7 +68,7 @@ describe('<Table />', () => {
<Table data={minProps.data} columns={columns} />,
);
expect(rendered.getByText('second value, first row')).toHaveStyle({
color: 'blue',
color: 'rgb(0, 0, 255)', // blue
});
});
@@ -85,7 +85,7 @@ describe('<Table />', () => {
<Table data={minProps.data} columns={columns} />,
);
expect(rendered.getByText('second value, first row')).toHaveStyle({
color: 'blue',
color: 'rgb(0, 0, 255)', // blue
'font-weight': 700,
});
});
@@ -115,10 +115,10 @@ describe('<Table />', () => {
<Table data={minProps.data} columns={columns} />,
);
expect(rendered.getByText('second value, first row')).toHaveStyle({
color: 'green',
color: 'rgb(0, 128, 0)', // green
});
expect(rendered.getByText('second value, second row')).toHaveStyle({
color: 'red',
color: 'rgb(255, 0, 0)', // red
});
});
@@ -135,11 +135,11 @@ describe('<Table />', () => {
<Table data={minProps.data} columns={columns} />,
);
expect(rendered.getByText('second value, first row')).toHaveStyle({
color: 'green',
color: 'rgb(0, 128, 0)', // green
'font-weight': 700,
});
expect(rendered.getByText('second value, second row')).toHaveStyle({
color: 'red',
color: 'rgb(255, 0, 0)', // red
'font-weight': 700,
});
});
@@ -165,11 +165,11 @@ describe('<Table />', () => {
expect(rendered.getByText(column1.title).closest('th')).not.toHaveStyle(
{
backgroundColor: 'pink',
backgroundColor: 'rgb(255, 192, 203)', // pink
},
);
expect(rendered.getByText(column2.title).closest('th')).toHaveStyle({
backgroundColor: 'pink',
backgroundColor: 'rgb(255, 192, 203)', // pink
});
});
@@ -67,18 +67,20 @@ describe('<ErrorBoundary/>', () => {
});
expect(error).toEqual([
expect.stringContaining('Error: Bomb'),
expect.objectContaining({
detail: new Error('Bomb'),
type: 'unhandled-exception',
}),
expect.stringContaining('Error: Bomb'),
expect.objectContaining({
detail: new Error('Bomb'),
type: 'unhandled-exception',
}),
expect.stringMatching(
/^The above error occurred in the <Bomb> component:/,
expect.stringContaining(
'The above error occurred in the <Bomb> component:',
),
expect.stringMatching(/^ErrorBoundary/),
expect.stringMatching(/Warning: findDOMNode/), // React warning, unfortunate but currently true
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Warning: findDOMNode'), // React warning, unfortunate but currently true
]);
expect(error.length).toEqual(5);
expect(error.length).toEqual(7);
});
});
@@ -96,7 +96,7 @@ describe('Items', () => {
it('should render a button with custom style', async () => {
expect(
await screen.findByRole('button', { name: /create/i }),
).toHaveStyle(`background-color: transparent`);
).toHaveStyle(`background-color: rgba(0, 0, 0, 0)`); // transparent
});
it('should send button clicks to analytics', async () => {
@@ -26,7 +26,6 @@ import {
createReactExtension,
createRoutableExtension,
} from './extensions';
import { ForwardedError } from '@backstage/errors';
jest.mock('../app');
@@ -117,7 +116,13 @@ describe('extensions', () => {
render(<BrokenComponent />);
});
screen.getByText('Error in my-plugin');
expect(errors[0]).toMatchObject({ detail: new Error('Test error') });
expect(errors).toEqual([
expect.stringContaining('Error: Test error'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: Test error'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('The above error occurred in'),
]);
});
it('should handle failed lazy loads', async () => {
@@ -154,12 +159,17 @@ describe('extensions', () => {
screen.getByText(
'Error in my-plugin: Error: Failed lazy loading of the BrokenComponent extension, try to reload the page; caused by Error: Test error',
);
expect(errors[0]).toMatchObject({
detail: new ForwardedError(
'Failed lazy loading of the BrokenComponent extension, try to reload the page',
new Error('Test error'),
expect(errors).toEqual([
expect.stringContaining(
'Error: Failed lazy loading of the BrokenComponent extension, try to reload the page',
),
});
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'Error: Failed lazy loading of the BrokenComponent extension, try to reload the page',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('The above error occurred in'),
]);
});
it('should wrap extended component with analytics context', async () => {
+1 -1
View File
@@ -23,7 +23,7 @@ describe('common', () => {
const error = new E('abcdef');
expect(error.name).toBe(name);
expect(error.message).toBe('abcdef');
expect(error.stack).toContain(__filename);
expect(error.stack).toContain('common.test.ts');
expect(error.toString()).toContain(name);
expect(error.toString()).toContain('abcdef');
}
@@ -655,7 +655,7 @@ describe('dynamicFrontendFeaturesLoader', () => {
const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]);
expect(errorCalls).toEqual([
`Failed fetching module federation configuration of dynamic frontend plugins: FetchError: invalid json response body at http://localhost:7007/.backstage/dynamic-features/remotes reason: Unexpected end of JSON input`,
`Failed fetching module federation configuration of dynamic frontend plugins: SyntaxError: Unexpected end of JSON input`,
]);
const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]);
expect(warnCalls).toEqual([]);
@@ -70,12 +70,10 @@ describe('AppNodeProvider', () => {
).toThrow('AppNodeContext v1 not available');
});
expect(error).toEqual([
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.stringContaining('Error: AppNodeContext v1 not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: AppNodeContext v1 not available'),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <TestComponent> component:',
),
+1 -1
View File
@@ -73,7 +73,7 @@ describe('azure core', () => {
},
{
url: 'com/a/b/blob/master/path/to/c.yaml',
error: 'Invalid URL: com/a/b/blob/master/path/to/c.yaml',
error: 'Invalid URL',
},
])('should handle error path %#', ({ url, error }) => {
expect(() => getAzureFileFetchUrl(url)).toThrow(error);
@@ -15,7 +15,7 @@
*/
import { render } from '@testing-library/react';
import { TechDocsLiveReload } from './LiveReloadAddon';
import { TechDocsLiveReload, utils } from './LiveReloadAddon';
jest.mock('@backstage/plugin-techdocs-react', () => ({
useShadowRootElements: jest.fn(() => [
@@ -38,12 +38,11 @@ jest.mock('@backstage/plugin-techdocs-react', () => ({
describe('TechDocsLiveReload', () => {
const originalXHR = global.XMLHttpRequest;
let originalLocation: Location;
let openSpy: jest.Mock;
let sendSpy: jest.Mock;
let reloadPageSpy: jest.SpyInstance;
beforeEach(() => {
originalLocation = window.location;
openSpy = jest.fn();
sendSpy = jest.fn(function (this: any) {
// simulate long-poll response that does NOT trigger reload (epoch unchanged)
@@ -65,9 +64,11 @@ describe('TechDocsLiveReload', () => {
global.XMLHttpRequest = MockXHR as any;
// Replace window.location with a mutable object for tests
delete (window as any).location;
(window as any).location = { ...originalLocation, reload: jest.fn() };
// Spy on the utils object's reloadPage method
reloadPageSpy = jest
.spyOn(utils, 'reloadPage')
.mockImplementation(() => {});
jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
jest.spyOn(window, 'removeEventListener').mockImplementation(() => {});
Object.defineProperty(document, 'visibilityState', {
@@ -79,23 +80,17 @@ describe('TechDocsLiveReload', () => {
afterEach(() => {
global.XMLHttpRequest = originalXHR;
jest.restoreAllMocks();
// restore original window.location
delete (window as any).location;
(window as any).location = originalLocation;
});
it('polls livereload endpoint and does not reload when epoch unchanged', async () => {
const reloadSpy = window.location.reload as unknown as jest.Mock;
render(<TechDocsLiveReload enabled />);
expect(openSpy).toHaveBeenCalledWith('GET', '/.livereload/10/1');
// give microtask queue a tick
await new Promise(res => setTimeout(res, 0));
expect(reloadSpy).not.toHaveBeenCalled();
expect(reloadPageSpy).not.toHaveBeenCalled();
});
it('reloads when server epoch increases', async () => {
const reloadSpy = window.location.reload as unknown as jest.Mock;
sendSpy.mockImplementation(function (this: any) {
setTimeout(() => {
(this as any).status = 200;
@@ -106,6 +101,6 @@ describe('TechDocsLiveReload', () => {
render(<TechDocsLiveReload enabled />);
await new Promise(res => setTimeout(res, 0));
expect(reloadSpy).toHaveBeenCalled();
expect(reloadPageSpy).toHaveBeenCalled();
});
});
@@ -17,6 +17,13 @@
import { useShadowRootElements } from '@backstage/plugin-techdocs-react';
import { useEffect, useRef } from 'react';
/** @internal Exported for testing - allows spying on reloads without spying
* on window.location.
*/
export const utils = {
reloadPage: () => window.location.reload(),
};
interface TechDocsLiveReloadProps {
/** Whether to enable livereload (default: true in development) */
enabled?: boolean;
@@ -69,7 +76,7 @@ export const TechDocsLiveReload = ({
reqRef.current = new XMLHttpRequest();
reqRef.current.onloadend = function handleLoadEnd(this: XMLHttpRequest) {
if (parseFloat(this.responseText) > epoch) {
window.location.reload();
utils.reloadPage();
} else {
timeoutRef.current = setTimeout(poll, this.status === 200 ? 0 : 3000);
}
@@ -127,7 +127,9 @@ function baseImplementation(
): typeof crossFetch {
const implementation = options?.baseImplementation;
if (!implementation) {
return crossFetch;
// Return a wrapper that evaluates global.fetch at call time, not construction time.
// This allows MSW to patch global.fetch after MockFetchApi is constructed.
return (input, init) => global.fetch(input, init);
} else if (implementation === 'none') {
return () => Promise.resolve(new Response());
}
@@ -92,17 +92,15 @@ describe('wrapInTestApp', () => {
});
expect(error).toEqual([
expect.objectContaining({
detail: new Error(
'MockErrorApi received unexpected error, Error: NOPE',
),
}),
expect.objectContaining({
detail: new Error(
'MockErrorApi received unexpected error, Error: NOPE',
),
}),
expect.stringMatching(/^The above error occurred in the <A> component:/),
expect.stringContaining(
'Error: MockErrorApi received unexpected error, Error: NOPE',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'Error: MockErrorApi received unexpected error, Error: NOPE',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('The above error occurred in the <A> component:'),
]);
});
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`GkeEntityProvider should return clusters as Resources 1`] = `
[MockFunction] {
@@ -1577,10 +1577,11 @@ describe('DefaultEntitiesCatalog', () => {
totalItems: 0,
items: {
type: 'raw',
entities: expect.objectContaining({ length: 10 }),
entities: expect.any(Array),
},
pageInfo: { nextCursor: expect.anything() },
});
expect(response.items.entities).toHaveLength(10);
response = await catalog.queryEntities({
...request,
cursor: response.pageInfo.nextCursor!,
@@ -1589,10 +1590,11 @@ describe('DefaultEntitiesCatalog', () => {
totalItems: 0,
items: {
type: 'raw',
entities: expect.objectContaining({ length: 5 }),
entities: expect.any(Array),
},
pageInfo: { prevCursor: expect.anything() },
});
expect(response.items.entities).toHaveLength(5);
},
);
@@ -327,9 +327,10 @@ describe('writeEntitiesResponse', () => {
expect(res.header['content-length']).not.toBeDefined();
expect(res.body).toEqual({
page: 1,
items: expect.objectContaining({ length: 300 }),
items: expect.any(Array),
totalItems: 1337,
});
expect(res.body.items).toHaveLength(300);
});
});
@@ -438,9 +439,10 @@ describe('writeEntitiesResponse', () => {
expect(res.header['content-length']).toBeDefined();
expect(res.body).toEqual({
page: 1,
items: expect.objectContaining({ length: 300 }),
items: expect.any(Array),
totalItems: 1337,
});
expect(res.body.items).toHaveLength(300);
});
});
});
@@ -68,15 +68,15 @@ describe('useAllEntitiesCount', () => {
),
});
await waitFor(() =>
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/owner'],
},
limit: 0,
}),
);
expect(result.current).toEqual({ count: 10, loading: false });
});
expect(result.current).toEqual({ count: 10, loading: false });
});
});
it(`shouldn't invoke the endpoint at startup, when filters are missing`, async () => {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CatalogClusterLocator returns the aws cluster details provided by annotations 1`] = `
{
@@ -21,11 +21,6 @@ describe('HeadlampClusterLinksFormatter', () => {
beforeEach(() => {
formatter = new HeadlampClusterLinksFormatter();
// Mock window.location.origin
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:3000' },
writable: true,
});
});
it('formats internal dashboard link correctly', async () => {
@@ -41,7 +36,7 @@ describe('HeadlampClusterLinksFormatter', () => {
const result = await formatter.formatClusterLink(options);
expect(result.toString()).toBe(
'http://localhost:3000/headlamp?to=%2Fc%2Ftest-cluster%2Fpods%2Fdefault%2Ftest-pod',
'http://localhost/headlamp?to=%2Fc%2Ftest-cluster%2Fpods%2Fdefault%2Ftest-pod',
);
});
@@ -148,9 +148,7 @@ describe('PermissionIntegrationClient', () => {
],
);
expect(response).toEqual(
expect.objectContaining([{ id: '123', result: AuthorizeResult.ALLOW }]),
);
expect(response).toEqual([{ id: '123', result: AuthorizeResult.ALLOW }]);
});
it('should not include authorization headers if no token is supplied', async () => {
@@ -514,11 +514,12 @@ describe('Stepper', () => {
const mockFormData = { firstName: 'John' };
Object.defineProperty(window, 'location', {
value: {
search: `?formData=${JSON.stringify(mockFormData)}`,
},
});
// Use history.replaceState to set the query string (jsdom 27 doesn't allow redefining window.location)
window.history.replaceState(
{},
'',
`?formData=${JSON.stringify(mockFormData)}`,
);
const { getByRole } = await renderInTestApp(
<SecretsContextProvider>
@@ -20,14 +20,6 @@ import { MockFileSystemAccess } from '../../../lib/filesystem/MockFileSystemAcce
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
Blob.prototype.text = async function text() {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsText(this);
});
};
describe('TemplateEditorBrowser', () => {
it('should render files and expand dirs without exploding', async () => {
await renderInTestApp(
@@ -33,7 +33,7 @@ describe('<VirtualizedListbox />', () => {
aria-expanded="true"
class="MuiAutocomplete-root MuiAutocomplete-hasClearIcon MuiAutocomplete-hasPopupIcon"
role="combobox"
style="position: relative; height: 18px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
style="position: relative; height: 18px; width: 100%; overflow: auto; -webkit-overflow-scrolling: touch; will-change: transform; direction: ltr;"
>
<div
style="height: 0px; width: 100%;"
@@ -50,7 +50,7 @@ describe('<VirtualizedListbox />', () => {
<div>
<div>
<div
style="position: relative; height: 18px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
style="position: relative; height: 18px; width: 100%; overflow: auto; -webkit-overflow-scrolling: touch; will-change: transform; direction: ltr;"
>
<div
style="height: 0px; width: 100%;"
@@ -71,7 +71,7 @@ describe('<VirtualizedListbox />', () => {
<div>
<div>
<div
style="position: relative; height: 54px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
style="position: relative; height: 54px; width: 100%; overflow: auto; -webkit-overflow-scrolling: touch; will-change: transform; direction: ltr;"
>
<div
style="height: 36px; width: 100%;"
@@ -100,7 +100,7 @@ describe('<VirtualizedListbox />', () => {
<div>
<div>
<div
style="position: relative; height: 378px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
style="position: relative; height: 378px; width: 100%; overflow: auto; -webkit-overflow-scrolling: touch; will-change: transform; direction: ltr;"
>
<div
style="height: 360px; width: 100%;"
@@ -184,7 +184,7 @@ describe('<VirtualizedListbox />', () => {
<div>
<div>
<div
style="position: relative; height: 378px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
style="position: relative; height: 378px; width: 100%; overflow: auto; -webkit-overflow-scrolling: touch; will-change: transform; direction: ltr;"
>
<div
style="height: 3600px; width: 100%;"
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { screen, waitFor } from '@testing-library/react';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MenuItem from '@material-ui/core/MenuItem';
@@ -312,10 +312,10 @@ describe('SearchResultGroup', () => {
await userEvent.click(screen.getByText('owner'));
await userEvent.type(
screen.getByRole('textbox'),
'{backspace}{backspace}{backspace}{backspace}techdocs-core',
);
// Use fireEvent.blur for contentEditable elements since userEvent.type with
// backspace doesn't work properly in jsdom (jsdom limitation, not a bug)
const textbox = screen.getByRole('textbox');
fireEvent.blur(textbox, { target: { textContent: 'techdocs-core' } });
await waitFor(() => {
expect(screen.getByText('techdocs-core')).toBeInTheDocument();
@@ -21,21 +21,13 @@ import { screen } from '@testing-library/react';
describe('handleMetaRedirects', () => {
const navigate = jest.fn();
const setUpNewTestShadowDom = async (
html: string,
rootHref: string,
rootPath: string,
) => {
const setUpNewTestShadowDom = async (html: string, rootHref: string) => {
const entityName = 'testEntity';
// Mock window.location.href for each test
Object.defineProperty(window, 'location', {
value: {
href: rootHref,
pathname: rootPath,
hostname: 'localhost',
},
writable: true,
});
// Use history.replaceState to change location (jsdom 27+ doesn't allow redefining location)
// Jest's jsdom starts at http://localhost/, so replaceState updates the pathname while
// keeping hostname and origin as 'localhost'.
const url = new URL(rootHref);
history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
return await createTestShadowDom(html, {
preTransformers: [],
postTransformers: [handleMetaRedirects(navigate, entityName)],
@@ -55,7 +47,6 @@ describe('handleMetaRedirects', () => {
await setUpNewTestShadowDom(
`<meta http-equiv="refresh" content="0; url=../anotherPage">`,
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
@@ -73,7 +64,6 @@ describe('handleMetaRedirects', () => {
await setUpNewTestShadowDom(
`<meta http-equiv="refresh" content="0; url=http://external.com/test">`,
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
@@ -91,7 +81,6 @@ describe('handleMetaRedirects', () => {
await setUpNewTestShadowDom(
`<meta http-equiv="refresh" content="0; url=http://localhost/test">`,
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(
@@ -107,7 +96,6 @@ describe('handleMetaRedirects', () => {
await setUpNewTestShadowDom(
`<meta name="keywords" content="TechDocs, Example">`,
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
jest.runAllTimers();
+1288 -991
View File
File diff suppressed because it is too large Load Diff