Merge pull request #21320 from backstage/mob/test-utils

add frontend-test-utils with createExtensionTester
This commit is contained in:
Patrik Oldsberg
2023-11-16 15:55:35 +01:00
committed by GitHub
21 changed files with 475 additions and 129 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Added `createExtensionTester` for rendering extensions in tests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': minor
---
New testing utility library for `@backstage/frontend-app-api` and `@backstage/frontend-plugin-api`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Added `createSpecializedApp`, which is a synchronous version of `createApp` where config and features already need to be loaded.
+9
View File
@@ -41,6 +41,15 @@ export function createApp(options?: {
// @public (undocumented)
export function createExtensionTree(options: { config: Config }): ExtensionTree;
// @public
export function createSpecializedApp(options?: {
features?: (BackstagePlugin | ExtensionOverrides)[];
config?: ConfigApi;
bindRoutes?(context: { bind: AppRouteBinder }): void;
}): {
createRoot(): JSX_2.Element;
};
// @public (undocumented)
export interface ExtensionTree {
// (undocumented)
@@ -244,49 +244,18 @@ export function createApp(options?: {
const discoveredFeatures = getAvailableFeatures(config);
const loadedFeatures = (await options?.featureLoader?.({ config })) ?? [];
const allFeatures = deduplicateFeatures([
...discoveredFeatures,
...loadedFeatures,
...(options?.features ?? []),
]);
const tree = createAppTree({
features: allFeatures,
builtinExtensions,
const app = createSpecializedApp({
config,
});
features: [
...discoveredFeatures,
...loadedFeatures,
...(options?.features ?? []),
],
bindRoutes: options?.bindRoutes,
}).createRoot();
const appContext = createLegacyAppContext(
allFeatures.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
),
);
const routeIds = collectRouteIds(allFeatures);
const App = () => (
<ApiProvider apis={createApiHolder(tree, config)}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
{...extractRouteInfoFromAppNode(tree.root)}
routeBindings={resolveRouteBindings(
options?.bindRoutes,
config,
routeIds,
)}
>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>
{tree.root.instance!.getData(coreExtensionData.reactElement)}
</BrowserRouter>
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
return { default: App };
return { default: () => app };
}
return {
@@ -301,6 +270,64 @@ export function createApp(options?: {
};
}
/**
* Synchronous version of {@link createApp}, expecting all features and
* config to have been loaded already.
* @public
*/
export function createSpecializedApp(options?: {
features?: (BackstagePlugin | ExtensionOverrides)[];
config?: ConfigApi;
bindRoutes?(context: { bind: AppRouteBinder }): void;
}): { createRoot(): JSX.Element } {
const {
features: duplicatedFeatures = [],
config = new ConfigReader({}, 'empty-config'),
} = options ?? {};
const features = deduplicateFeatures(duplicatedFeatures);
const tree = createAppTree({
features,
builtinExtensions,
config,
});
const appContext = createLegacyAppContext(
features.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
),
);
const apiHolder = createApiHolder(tree, config);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
config,
collectRouteIds(features),
);
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
const App = () => (
<ApiProvider apis={apiHolder}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>{rootEl}</BrowserRouter>
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
return {
createRoot() {
return <App />;
},
};
}
// Make sure that we only convert each new plugin instance to its legacy equivalent once
const legacyPluginStore = getOrCreateGlobalSingleton(
'legacy-plugin-compatibility-store',
@@ -16,6 +16,7 @@
export {
createApp,
createSpecializedApp,
createExtensionTree,
type ExtensionTreeNode,
type ExtensionTree,
@@ -25,6 +25,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
@@ -16,44 +16,12 @@
import React, { useEffect } from 'react';
import { screen, waitFor } from '@testing-library/react';
import {
MockAnalyticsApi,
MockConfigApi,
TestApiProvider,
renderWithEffects,
} from '@backstage/test-utils';
import { MockAnalyticsApi, TestApiProvider } from '@backstage/test-utils';
import { ExtensionBoundary } from './ExtensionBoundary';
import {
Extension,
coreExtensionData,
createExtension,
createPlugin,
} from '../wiring';
import { coreExtensionData, createExtension } from '../wiring';
import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
import { createApp } from '@backstage/frontend-app-api';
import { JsonObject } from '@backstage/types';
import { createRouteRef } from '../routing';
function renderExtensionInTestApp(
extension: Extension<unknown>,
options?: {
config?: JsonObject;
},
) {
const { config = {} } = options ?? {};
const app = createApp({
features: [
createPlugin({
id: 'plugin',
extensions: [extension],
}),
],
configLoader: async () => new MockConfigApi(config),
});
return renderWithEffects(app.createRoot());
}
import { createExtensionTester } from '@backstage/frontend-test-utils';
const wrapInBoundaryExtension = (element: JSX.Element) => {
const id = 'plugin.extension';
@@ -86,7 +54,7 @@ describe('ExtensionBoundary', () => {
const TextComponent = () => {
return <p>{text}</p>;
};
await renderExtensionInTestApp(wrapInBoundaryExtension(<TextComponent />));
createExtensionTester(wrapInBoundaryExtension(<TextComponent />)).render();
await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument());
});
@@ -95,7 +63,7 @@ describe('ExtensionBoundary', () => {
const ErrorComponent = () => {
throw new Error(error);
};
await renderExtensionInTestApp(wrapInBoundaryExtension(<ErrorComponent />));
createExtensionTester(wrapInBoundaryExtension(<ErrorComponent />)).render();
await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument());
});
@@ -112,13 +80,13 @@ describe('ExtensionBoundary', () => {
return null;
};
await renderExtensionInTestApp(
createExtensionTester(
wrapInBoundaryExtension(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<AnalyticsComponent />
</TestApiProvider>,
),
);
).render();
await waitFor(() =>
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+12
View File
@@ -0,0 +1,12 @@
# @backstage/frontend-test-utils
Contains utilities that can be used when testing frontend features such as extensions.
## Installation
Install the package via Yarn into your own packages:
```sh
cd <package-dir> # if within a monorepo
yarn add --dev @backstage/frontend-test-utils
```
@@ -0,0 +1,30 @@
## API Report File for "@backstage/frontend-test-utils"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Extension } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RenderResult } from '@testing-library/react';
// @public (undocumented)
export function createExtensionTester<TConfig>(
subject: Extension<TConfig>,
options?: {
config?: TConfig;
},
): ExtensionTester;
// @public (undocumented)
export class ExtensionTester {
// (undocumented)
add<TConfig>(
extension: Extension<TConfig>,
options?: {
config?: TConfig;
},
): ExtensionTester;
// (undocumented)
render(options?: { config?: JsonObject }): RenderResult;
}
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-frontend-test-utils
title: '@backstage/frontend-test-utils'
spec:
lifecycle: experimental
type: backstage-web-library
owner: maintainers
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@backstage/frontend-test-utils",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "web-library"
},
"sideEffects": false,
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@testing-library/jest-dom": "^6.0.0"
},
"files": [
"dist"
],
"dependencies": {
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@backstage/types": "workspace:^"
},
"peerDependencies": {
"@testing-library/react": "^12.1.3 || ^13.0.0 || ^14.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
}
}
@@ -0,0 +1,68 @@
/*
* 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.
* 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 {
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
import { screen } from '@testing-library/react';
import React from 'react';
import { createExtensionTester } from './createExtensionTester';
describe('createExtensionTester', () => {
it('should render a simple extension', async () => {
createExtensionTester(
createExtension({
id: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div>test</div> }),
}),
).render();
await expect(screen.findByText('test')).resolves.toBeInTheDocument();
});
it('should render an extension even if disabled by default', async () => {
createExtensionTester(
createExtension({
id: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
disabled: true,
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div>test</div> }),
}),
).render();
await expect(screen.findByText('test')).resolves.toBeInTheDocument();
});
it("should fail to render an extension that doesn't output a react element", async () => {
expect(() =>
createExtensionTester(
createExtension({
id: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
disabled: true,
output: { path: coreExtensionData.routePath },
factory: () => ({ path: '/foo' }),
}),
).render(),
).toThrow(
"Failed to instantiate extension 'core', input 'root' did not receive required extension data 'core.reactElement' from extension 'test'",
);
});
});
@@ -0,0 +1,114 @@
/*
* 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.
* 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 { createSpecializedApp } from '@backstage/frontend-app-api';
import { Extension, createPlugin } from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
import { RenderResult, render } from '@testing-library/react';
/** @public */
export class ExtensionTester {
/** @internal */
static forSubject<TConfig>(
subject: Extension<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const tester = new ExtensionTester();
tester.add(subject, options);
return tester;
}
readonly #extensions = new Array<{
extension: Extension<any>;
config?: JsonValue;
}>();
add<TConfig>(
extension: Extension<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
this.#extensions.push({
extension,
config: options?.config as JsonValue,
});
return this;
}
render(options?: { config?: JsonObject }): RenderResult {
const { config = {} } = options ?? {};
const [subject, ...rest] = this.#extensions;
if (!subject) {
throw new Error(
'No subject found. At least one extension should be added to the tester.',
);
}
const extensionsConfig: JsonArray = [
...rest.map(entry => ({
[entry.extension.id]: {
config: entry.config,
},
})),
{
[subject.extension.id]: {
attachTo: { id: 'core', input: 'root' },
config: subject.config,
disabled: false,
},
},
{
'core.layout': false,
},
{
'core.nav': false,
},
{
'core.routes': false,
},
];
const finalConfig = {
...config,
app: {
...(typeof config.app === 'object' ? config.app : undefined),
extensions: extensionsConfig,
},
};
const app = createSpecializedApp({
features: [
createPlugin({
id: 'test',
extensions: this.#extensions.map(entry => entry.extension),
}),
],
config: new MockConfigApi(finalConfig),
});
return render(app.createRoot());
}
}
/** @public */
export function createExtensionTester<TConfig>(
subject: Extension<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
return ExtensionTester.forSubject(subject, options);
}
@@ -0,0 +1,20 @@
/*
* 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.
* 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.
*/
export {
createExtensionTester,
type ExtensionTester,
} from './createExtensionTester';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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.
* 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.
*/
/**
* @packageDocumentation
*
* Contains utilities that can be used when testing frontend features such as extensions.
*/
export * from './app';
@@ -0,0 +1,17 @@
/*
* 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.
* 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 '@testing-library/jest-dom';
+1
View File
@@ -69,6 +69,7 @@
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.0.0",
+20 -50
View File
@@ -14,41 +14,28 @@
* limitations under the License.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import {
createExtensionInput,
createPageExtension,
createPlugin,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { SearchResult } from '@backstage/plugin-search-common';
import { createApp } from '@backstage/frontend-app-api';
import { MockConfigApi } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import {
BaseSearchResultListItemProps,
createSearchResultListItemExtension,
searchResultItemExtensionData as searchResultListItemExtensionData,
} from './alpha';
// TODO: Remove this mock when we have a permanent solution for nav items extensions
// The `GraphiQLIcon` used in "packages/frontend-app-api/src/extensions/CoreNav.tsx" file
// is throwing a "ReferenceError: ref is not defined" error during test
jest.mock('@backstage/plugin-graphiql', () => ({
...jest.requireActual('@backstage/plugin-graphiql'),
GraphiQLIcon: () => null,
}));
describe('createSearchResultListItemExtension', () => {
it('Should use the correct result component', async () => {
type TechDocsSearchReasulListItemProps = BaseSearchResultListItemProps<{
type TechDocsSearchResultListItemProps = BaseSearchResultListItemProps<{
lineClamp: number;
}>;
const TechDocsSearchResultItemComponent = (
props: TechDocsSearchReasulListItemProps,
props: TechDocsSearchResultListItemProps,
) => (
<div>
TechDocs - Rank: {props.rank} - Line clamp: {props.lineClamp}
@@ -60,10 +47,12 @@ describe('createSearchResultListItemExtension', () => {
id: 'techdocs',
attachTo: { id: 'plugin.search.page', input: 'items' },
configSchema: createSchemaFromZod(z =>
z.object({
noTrack: z.boolean().default(true),
lineClamp: z.number().default(5),
}),
z
.object({
noTrack: z.boolean().default(true),
lineClamp: z.number().default(5),
})
.default({}),
),
predicate: result => result.type === 'techdocs',
component:
@@ -158,34 +147,15 @@ describe('createSearchResultListItemExtension', () => {
},
});
const SearchPlugin = createPlugin({
id: 'search.plugin',
extensions: [
SearchPageExtension,
ExploreSearchResultItemExtension,
TechDocsSearchResultItemExtension,
],
});
const app = createApp({
features: [SearchPlugin],
configLoader: async () =>
new MockConfigApi({
app: {
extensions: [
{
'plugin.search.result.item.techdocs': {
config: {
lineClamp: 3,
},
},
},
],
},
}),
});
render(app.createRoot());
createExtensionTester(SearchPageExtension)
.add(TechDocsSearchResultItemExtension, {
// TODO(Rugvip): We need to make the config input type available for use here
config: {
lineClamp: 3,
} as any,
})
.add(ExploreSearchResultItemExtension)
.render();
expect(await screen.findByText(/Search Page/)).toBeInTheDocument();
+18
View File
@@ -4354,6 +4354,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
@@ -4371,6 +4372,22 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/frontend-test-utils@workspace:^, @backstage/frontend-test-utils@workspace:packages/frontend-test-utils":
version: 0.0.0-use.local
resolution: "@backstage/frontend-test-utils@workspace:packages/frontend-test-utils"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@testing-library/jest-dom": ^6.0.0
peerDependencies:
"@testing-library/react": ^12.1.3 || ^13.0.0 || ^14.0.0
react: ^16.13.1 || ^17.0.0 || ^18.0.0
languageName: unknown
linkType: soft
"@backstage/integration-aws-node@workspace:^, @backstage/integration-aws-node@workspace:packages/integration-aws-node":
version: 0.0.0-use.local
resolution: "@backstage/integration-aws-node@workspace:packages/integration-aws-node"
@@ -9117,6 +9134,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"