Merge pull request #21782 from backstage/camilal/di-docs-plugin-test
[DI] Draft frontend plugins testing documentation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': patch
|
||||
---
|
||||
|
||||
The `createExtensionTester` helper is now able to render more than one route in the test app.
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
id: testing
|
||||
title: Frontend System Testing Plugins
|
||||
sidebar_label: Testing
|
||||
# prettier-ignore
|
||||
description: Testing plugins in the frontend system
|
||||
---
|
||||
|
||||
> **NOTE: The new frontend system is in a highly experimental phase**
|
||||
|
||||
# Testing Frontend Plugins
|
||||
|
||||
> NOTE: The new frontend system is in alpha, and some plugins do not yet fully implement it.
|
||||
|
||||
Utilities for testing frontend features and components are available in `@backstage/frontend-test-utils`.
|
||||
|
||||
## Testing React components
|
||||
|
||||
A component can be used for more than one extension, and it should be tested independently of an extension environment.
|
||||
|
||||
Use the `renderInTestApp` helper to render a given component inside a Backstage test app:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/frontend-test-utils';
|
||||
import { EntityDetails } from './plugin';
|
||||
|
||||
describe('Entity details component', () => {
|
||||
it('should render the entity name and owner', async () => {
|
||||
await renderInTestApp(<EntityDetails owner="tools" name="test" />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('The entity "test" is owned by "tools"'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
To mock [Utility APIs](../architecture/06-utility-apis.md) that are used by your component you can use the `TestApiProvider` to override individual API implementations. In the snippet below, we wrap the component within a `TestApiProvider` in order to mock the catalog client API:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { EntityDetails } from './plugin';
|
||||
|
||||
describe('Entity details component', () => {
|
||||
it('should render the entity name and owner', async () => {
|
||||
const catalogApiMock = {
|
||||
async getEntityFacets() {
|
||||
return {
|
||||
facets: {
|
||||
'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }],
|
||||
},
|
||||
},
|
||||
}
|
||||
} satisfies Partial<typeof catalogApiRef.T>;
|
||||
|
||||
const entityRef = stringifyEntityRef({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
|
||||
<EntityDetails entitRef={entityRef} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('The entity "test" is owned by "tools"'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing extensions
|
||||
|
||||
To facilitate testing of frontend extensions, the `@backstage/frontend-test-utils` package provides a tester class which starts up an entire frontend harness, complete with a number of default features. You can then provide overrides for extensions whose behavior you need to adjust for the test run.
|
||||
|
||||
A number of features (frontend extensions and overrides) are also accepted by the tester. Here are some examples of how these facilities can be useful:
|
||||
|
||||
### Single extension
|
||||
|
||||
In order to test an extension in isolation, you simply need to pass it into the tester factory, then call the render method on the returned instance:
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import { createExtensionTester } from '@backstage/frontend-test-utils';
|
||||
import { indexPageExtension } from './plugin';
|
||||
|
||||
describe('Index page', () => {
|
||||
it('should render a the index page', () => {
|
||||
createExtensionTester(indexPageExtension).render();
|
||||
|
||||
expect(screen.getByText('Index Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Extension preset
|
||||
|
||||
There are some extensions that rely on other extensions existence, such as a page that links to another page. In that case, you can add more than one extension to the preset of features you want to render in the test, as shown below:
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createExtensionTester } from '@backstage/frontend-test-utils';
|
||||
import { indexPageExtension, detailsPageExtension } from './plugin';
|
||||
|
||||
describe('Index page', async () => {
|
||||
it('should link to the details page', () => {
|
||||
createExtensionTester(indexPageExtension)
|
||||
// Adding more extensions to the preset being tested
|
||||
.add(detailsPageExtension)
|
||||
.render();
|
||||
|
||||
await expect(screen.findByText('Index Page')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('link', { name: 'See details' }));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Details Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking apis
|
||||
|
||||
If your extensions requires implementation of APIs that aren't wired up by default, you'll have to add overrides to the preset of features being tested:
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createApiFactory } from '@backestage/core-plugin-api';
|
||||
import {
|
||||
createExtensionOverrides,
|
||||
configApiRef,
|
||||
analyticsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createExtensionTester,
|
||||
MockConfigApi,
|
||||
MockAnalyticsApi,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
import { indexPageExtension } from './plugin';
|
||||
|
||||
describe('Index page', () => {
|
||||
it('should capture click events in analytics', async () => {
|
||||
// Mocking the analytics api implementation
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
|
||||
const analyticsApiOverride = createApiExtension({
|
||||
factory: createApiFactory({
|
||||
api: analyticsApiRef,
|
||||
factory: () => analyticsApiMock,
|
||||
}),
|
||||
});
|
||||
|
||||
createExtensionTester(indexPageExtension)
|
||||
// Overriding the analytics api extension
|
||||
.add(analyticsApiOverride)
|
||||
.render();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('link', { name: 'See details' }),
|
||||
);
|
||||
|
||||
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
subject: 'See details',
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Setting configuration
|
||||
|
||||
In the case that your extension can be configured, you can test this capability by passing configuration values as follows:
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createExtensionTester } from '@backstage/frontend-test-utils';
|
||||
import { indexPageExtension, detailsPageExtension } from './plugin';
|
||||
|
||||
describe('Index page', () => {
|
||||
it('should accepts a custom title via config', async () => {
|
||||
createExtensionTester(indexPageExtension, {
|
||||
// Configuration specific of index page
|
||||
config: { title: 'Custom index' },
|
||||
})
|
||||
.add(detailsExtensionPage, {
|
||||
// Configuration specific of details page
|
||||
config: { title: 'Custom details' },
|
||||
})
|
||||
.render({
|
||||
// Configuration specific of the instance
|
||||
config: {
|
||||
app: {
|
||||
title: 'Custom app',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByRole('heading', { name: 'Custom app' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('heading', { name: 'Custom index' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('link', { name: 'See details' }));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Custom details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
That's all for testing features!
|
||||
|
||||
## Missing something?
|
||||
|
||||
If there's anything else you think needs to be covered in the docs or that you think isn't covered by the test utilities, please create an issue in the Backstage repository. You are always welcome to contribute as well!
|
||||
@@ -270,6 +270,25 @@ describe('createApp', () => {
|
||||
<theme:app/light out=[core.theme.theme] />
|
||||
<theme:app/dark out=[core.theme.theme] />
|
||||
]
|
||||
apis [
|
||||
<api:core.discovery out=[core.api.factory] />
|
||||
<api:core.alert out=[core.api.factory] />
|
||||
<api:core.analytics out=[core.api.factory] />
|
||||
<api:core.error out=[core.api.factory] />
|
||||
<api:core.storage out=[core.api.factory] />
|
||||
<api:core.fetch out=[core.api.factory] />
|
||||
<api:core.oauthrequest out=[core.api.factory] />
|
||||
<api:core.auth.google out=[core.api.factory] />
|
||||
<api:core.auth.microsoft out=[core.api.factory] />
|
||||
<api:core.auth.github out=[core.api.factory] />
|
||||
<api:core.auth.okta out=[core.api.factory] />
|
||||
<api:core.auth.gitlab out=[core.api.factory] />
|
||||
<api:core.auth.onelogin out=[core.api.factory] />
|
||||
<api:core.auth.bitbucket out=[core.api.factory] />
|
||||
<api:core.auth.bitbucket-server out=[core.api.factory] />
|
||||
<api:core.auth.atlassian out=[core.api.factory] />
|
||||
<api:plugin.permission.api out=[core.api.factory] />
|
||||
]
|
||||
</core>"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -103,6 +103,8 @@ import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/w
|
||||
import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi';
|
||||
import { stringifyError } from '@backstage/errors';
|
||||
|
||||
const DefaultApis = defaultApis.map(factory => createApiExtension({ factory }));
|
||||
|
||||
export const builtinExtensions = [
|
||||
Core,
|
||||
CoreRouter,
|
||||
@@ -114,6 +116,7 @@ export const builtinExtensions = [
|
||||
DefaultNotFoundErrorPageComponent,
|
||||
LightTheme,
|
||||
DarkTheme,
|
||||
...DefaultApis,
|
||||
].map(def => resolveExtensionDefinition(def));
|
||||
|
||||
/** @public */
|
||||
@@ -418,7 +421,7 @@ function createApiHolder(
|
||||
(x): x is typeof createTranslationExtension.translationDataRef.T => !!x,
|
||||
) ?? [];
|
||||
|
||||
for (const factory of [...defaultApis, ...pluginApis]) {
|
||||
for (const factory of pluginApis) {
|
||||
factoryRegistry.register('default', factory);
|
||||
}
|
||||
|
||||
@@ -496,15 +499,6 @@ function createApiHolder(
|
||||
}),
|
||||
});
|
||||
|
||||
// TODO: ship these as default extensions instead
|
||||
for (const factory of defaultApis as AnyApiFactory[]) {
|
||||
if (!factoryRegistry.register('app', factory)) {
|
||||
throw new Error(
|
||||
`Duplicate or forbidden API factory for ${factory.api} in app`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
|
||||
|
||||
return new ApiResolver(factoryRegistry);
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
|
||||
import { ErrorWithContext } from '@backstage/test-utils';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { MockAnalyticsApi } from '@backstage/test-utils';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
import { MockErrorApiOptions } from '@backstage/test-utils';
|
||||
@@ -47,7 +48,13 @@ export class ExtensionTester {
|
||||
render(options?: { config?: JsonObject }): RenderResult;
|
||||
}
|
||||
|
||||
export { MockAnalyticsApi };
|
||||
// @public
|
||||
export class MockAnalyticsApi implements AnalyticsApi {
|
||||
// (undocumented)
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
// (undocumented)
|
||||
getEvents(): AnalyticsEvent[];
|
||||
}
|
||||
|
||||
export { MockConfigApi };
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0"
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@types/react": "*"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -37,6 +38,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/react": "^12.1.3 || ^13.0.0 || ^14.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { MockAnalyticsApi } from './MockAnalyticsApi';
|
||||
|
||||
describe('MockAnalyticsApi', () => {
|
||||
const context = {
|
||||
pluginId: 'some-plugin',
|
||||
extensionId: 'some-extension',
|
||||
};
|
||||
|
||||
it('should collect events', () => {
|
||||
const api = new MockAnalyticsApi();
|
||||
|
||||
api.captureEvent({ action: 'action-1', subject: 'subject-1', context });
|
||||
api.captureEvent({
|
||||
action: 'action-2',
|
||||
subject: 'subject-2',
|
||||
value: 42,
|
||||
context,
|
||||
});
|
||||
api.captureEvent({
|
||||
action: 'action-3',
|
||||
subject: 'subject-3',
|
||||
value: 1337,
|
||||
attributes: { some: 'context' },
|
||||
context,
|
||||
});
|
||||
|
||||
expect(api.getEvents()[0]).toMatchObject({
|
||||
subject: 'subject-1',
|
||||
action: 'action-1',
|
||||
context,
|
||||
});
|
||||
expect(api.getEvents()[1]).toMatchObject({
|
||||
subject: 'subject-2',
|
||||
action: 'action-2',
|
||||
value: 42,
|
||||
context,
|
||||
});
|
||||
expect(api.getEvents()[2]).toMatchObject({
|
||||
subject: 'subject-3',
|
||||
action: 'action-3',
|
||||
value: 1337,
|
||||
context,
|
||||
attributes: {
|
||||
some: 'context',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link frontend-plugin-api#AnalyticsApi} with helpers to ensure that events are sent correctly.
|
||||
* Use getEvents in tests to verify captured events.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MockAnalyticsApi implements AnalyticsApi {
|
||||
private events: AnalyticsEvent[] = [];
|
||||
|
||||
captureEvent(event: AnalyticsEvent) {
|
||||
const { action, subject, value, attributes, context } = event;
|
||||
|
||||
this.events.push({
|
||||
action,
|
||||
subject,
|
||||
context,
|
||||
...(value !== undefined ? { value } : {}),
|
||||
...(attributes !== undefined ? { attributes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
getEvents(): AnalyticsEvent[] {
|
||||
return this.events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { MockAnalyticsApi } from './MockAnalyticsApi';
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
MockAnalyticsApi,
|
||||
MockConfigApi,
|
||||
type ErrorWithContext,
|
||||
MockErrorApi,
|
||||
@@ -26,3 +25,5 @@ export {
|
||||
MockStorageApi,
|
||||
type MockStorageBucket,
|
||||
} from '@backstage/test-utils';
|
||||
|
||||
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
|
||||
|
||||
@@ -14,55 +14,215 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
analyticsApiRef,
|
||||
configApiRef,
|
||||
coreExtensionData,
|
||||
createApiExtension,
|
||||
createApiFactory,
|
||||
createExtension,
|
||||
createSchemaFromZod,
|
||||
useAnalytics,
|
||||
useApi,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockAnalyticsApi } from '../apis';
|
||||
import { createExtensionTester } from './createExtensionTester';
|
||||
|
||||
describe('createExtensionTester', () => {
|
||||
it('should render a simple extension', async () => {
|
||||
createExtensionTester(
|
||||
createExtension({
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: { element: coreExtensionData.reactElement },
|
||||
factory: () => ({ element: <div>test</div> }),
|
||||
}),
|
||||
).render();
|
||||
const defaultDefinition = {
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: { element: coreExtensionData.reactElement },
|
||||
factory: () => ({ element: <div>test</div> }),
|
||||
};
|
||||
|
||||
it('should render a simple extension', async () => {
|
||||
const extension = createExtension(defaultDefinition);
|
||||
const tester = createExtensionTester(extension);
|
||||
tester.render();
|
||||
await expect(screen.findByText('test')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an extension even if disabled by default', async () => {
|
||||
createExtensionTester(
|
||||
createExtension({
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
disabled: true,
|
||||
output: { element: coreExtensionData.reactElement },
|
||||
factory: () => ({ element: <div>test</div> }),
|
||||
}),
|
||||
).render();
|
||||
|
||||
const extension = createExtension({
|
||||
...defaultDefinition,
|
||||
disabled: true,
|
||||
});
|
||||
const tester = createExtensionTester(extension);
|
||||
tester.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({
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
disabled: true,
|
||||
output: { path: coreExtensionData.routePath },
|
||||
factory: () => ({ path: '/foo' }),
|
||||
}),
|
||||
).render(),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core/router', input 'children' did not receive required extension data 'core.reactElement' from extension 'test'",
|
||||
const extension = createExtension({
|
||||
...defaultDefinition,
|
||||
output: { path: coreExtensionData.routePath },
|
||||
factory: () => ({ path: '/foo' }),
|
||||
});
|
||||
const tester = createExtensionTester(extension);
|
||||
expect(() => tester.render()).toThrow(
|
||||
"Failed to instantiate extension 'core/routes', input 'routes' did not receive required extension data 'core.reactElement' from extension 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should render multiple extensions', async () => {
|
||||
const indexPageExtension = createExtension({
|
||||
...defaultDefinition,
|
||||
factory: () => ({
|
||||
element: (
|
||||
<div>
|
||||
Index page <Link to="/details">See details</Link>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
});
|
||||
const detailsPageExtension = createExtension({
|
||||
...defaultDefinition,
|
||||
name: 'details',
|
||||
attachTo: { id: 'core/routes', input: 'routes' },
|
||||
output: {
|
||||
path: coreExtensionData.routePath,
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
factory: () => ({ path: '/details', element: <div>Details page</div> }),
|
||||
});
|
||||
|
||||
const tester = createExtensionTester(indexPageExtension);
|
||||
tester.add(detailsPageExtension);
|
||||
tester.render();
|
||||
|
||||
await expect(screen.findByText('Index page')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Details page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accepts a custom config', async () => {
|
||||
const indexPageExtension = createExtension({
|
||||
...defaultDefinition,
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ title: z.string().optional() }),
|
||||
),
|
||||
factory: ({ config }) => {
|
||||
const Component = () => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const appTitle = configApi.getOptionalString('app.title');
|
||||
return (
|
||||
<div>
|
||||
<h2>{appTitle ?? 'Backstafe app'}</h2>
|
||||
<h3>{config.title ?? 'Index page'}</h3>
|
||||
<Link to="/details">See details</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return {
|
||||
element: <Component />,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const detailsPageExtension = createExtension({
|
||||
...defaultDefinition,
|
||||
name: 'details',
|
||||
attachTo: { id: 'core/routes', input: 'routes' },
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ title: z.string().optional() }),
|
||||
),
|
||||
output: {
|
||||
path: coreExtensionData.routePath,
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
factory: ({ config }) => ({
|
||||
path: '/details',
|
||||
element: <div>{config.title ?? 'Details page'}</div>,
|
||||
}),
|
||||
});
|
||||
|
||||
const tester = createExtensionTester(indexPageExtension, {
|
||||
config: { title: 'Custom index' },
|
||||
});
|
||||
|
||||
tester.add(detailsPageExtension, {
|
||||
config: { title: 'Custom details' },
|
||||
});
|
||||
|
||||
tester.render({
|
||||
config: {
|
||||
app: {
|
||||
title: 'Custom app',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByRole('heading', { name: 'Custom app' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('heading', { name: 'Custom index' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Custom details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should capture click events in analytics', async () => {
|
||||
// Mocking the analytics api implementation
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
|
||||
const analyticsApiOverride = createApiExtension({
|
||||
factory: createApiFactory({
|
||||
api: analyticsApiRef,
|
||||
deps: {},
|
||||
factory: () => analyticsApiMock,
|
||||
}),
|
||||
});
|
||||
|
||||
const indexPageExtension = createExtension({
|
||||
...defaultDefinition,
|
||||
factory: () => {
|
||||
const Component = () => {
|
||||
const analyticsApi = useAnalytics();
|
||||
const handleClick = useCallback(() => {
|
||||
analyticsApi.captureEvent('click', 'See details');
|
||||
}, [analyticsApi]);
|
||||
return (
|
||||
<div>
|
||||
Index Page
|
||||
<button onClick={handleClick}>See details</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
element: <Component />,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const tester = createExtensionTester(indexPageExtension);
|
||||
|
||||
// Overriding the analytics api extension
|
||||
tester.add(analyticsApiOverride);
|
||||
|
||||
tester.render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'See details' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
subject: 'See details',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
ExtensionDefinition,
|
||||
createExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
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: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
const tester = new ExtensionTester();
|
||||
tester.add(subject, options);
|
||||
return tester;
|
||||
}
|
||||
|
||||
readonly #extensions = new Array<{
|
||||
id: string;
|
||||
extension: ExtensionDefinition<any>;
|
||||
config?: JsonValue;
|
||||
}>();
|
||||
|
||||
add<TConfig>(
|
||||
extension: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
const withNamespace = {
|
||||
...extension,
|
||||
name: !extension.namespace && !extension.name ? 'test' : extension.name,
|
||||
};
|
||||
this.#extensions.push({
|
||||
id: resolveExtensionDefinition(withNamespace).id,
|
||||
extension: withNamespace,
|
||||
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.id]: {
|
||||
config: entry.config,
|
||||
},
|
||||
})),
|
||||
{
|
||||
[subject.id]: {
|
||||
attachTo: { id: 'core/router', input: 'children' },
|
||||
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: [
|
||||
createExtensionOverrides({
|
||||
extensions: this.#extensions.map(entry => entry.extension),
|
||||
}),
|
||||
],
|
||||
config: new MockConfigApi(finalConfig),
|
||||
});
|
||||
|
||||
return render(app.createRoot());
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createExtensionTester<TConfig>(
|
||||
subject: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
return ExtensionTester.forSubject(subject, options);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* 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 React, { ComponentType, ReactNode, useContext, useState } from 'react';
|
||||
import { MemoryRouter, Link } from 'react-router-dom';
|
||||
import { RenderResult, render } from '@testing-library/react';
|
||||
import { createSpecializedApp } from '@backstage/frontend-app-api';
|
||||
import {
|
||||
ExtensionDefinition,
|
||||
IconComponent,
|
||||
IdentityApi,
|
||||
RouteRef,
|
||||
configApiRef,
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
createExtensionInput,
|
||||
createExtensionOverrides,
|
||||
createNavItemExtension,
|
||||
useApi,
|
||||
useRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { SignInPageProps } from '../../../core-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { getBasePath } from '../../../core-app-api/src/app/AppRouter';
|
||||
|
||||
const NavItem = (props: {
|
||||
routeRef: RouteRef<undefined>;
|
||||
title: string;
|
||||
icon: IconComponent;
|
||||
}) => {
|
||||
const { routeRef, title, icon: Icon } = props;
|
||||
const to = useRouteRef(routeRef)();
|
||||
return (
|
||||
<li>
|
||||
<Link to={to}>
|
||||
<Icon /> {title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const TestCoreNavExtension = createExtension({
|
||||
namespace: 'core',
|
||||
name: 'nav',
|
||||
attachTo: { id: 'core/layout', input: 'nav' },
|
||||
inputs: {
|
||||
items: createExtensionInput({
|
||||
target: createNavItemExtension.targetDataRef,
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
factory({ inputs }) {
|
||||
return {
|
||||
element: (
|
||||
<nav>
|
||||
<ul>
|
||||
{inputs.items.map((item, index) => (
|
||||
<NavItem
|
||||
key={index}
|
||||
icon={item.output.target.icon}
|
||||
title={item.output.target.title}
|
||||
routeRef={item.output.target.routeRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const AuthenticationProvider = (props: {
|
||||
signInPage?: ComponentType<SignInPageProps>;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { signInPage: SignInPage, children } = props;
|
||||
const configApi = useApi(configApiRef);
|
||||
const signOutTargetUrl = getBasePath(configApi) || '/';
|
||||
|
||||
const internalAppContext = useContext(InternalAppContext);
|
||||
if (!internalAppContext) {
|
||||
throw new Error('AppRouter must be rendered within the AppProvider');
|
||||
}
|
||||
|
||||
const { appIdentityProxy } = internalAppContext;
|
||||
const [identityApi, setIdentityApi] = useState<IdentityApi>();
|
||||
|
||||
if (!SignInPage) {
|
||||
appIdentityProxy.setTarget(
|
||||
{
|
||||
getUserId: () => 'guest',
|
||||
getIdToken: async () => undefined,
|
||||
getProfile: () => ({
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
}),
|
||||
getProfileInfo: async () => ({
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
}),
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/guest',
|
||||
ownershipEntityRefs: ['user:default/guest'],
|
||||
}),
|
||||
getCredentials: async () => ({}),
|
||||
signOut: async () => {},
|
||||
},
|
||||
{ signOutTargetUrl },
|
||||
);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!identityApi) {
|
||||
return <SignInPage onSignInSuccess={setIdentityApi} />;
|
||||
}
|
||||
|
||||
appIdentityProxy.setTarget(identityApi, {
|
||||
signOutTargetUrl,
|
||||
});
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
const TestCoreRouterExtension = createExtension({
|
||||
namespace: 'core',
|
||||
name: 'router',
|
||||
attachTo: { id: 'core', input: 'root' },
|
||||
inputs: {
|
||||
signInPage: createExtensionInput(
|
||||
{
|
||||
component: createSignInPageExtension.componentDataRef,
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
children: createExtensionInput(
|
||||
{
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {
|
||||
element: coreExtensionData.reactElement,
|
||||
},
|
||||
factory({ inputs }) {
|
||||
const SignInPage = inputs.signInPage?.output.component;
|
||||
const children = inputs.children.output.element;
|
||||
|
||||
return {
|
||||
element: (
|
||||
<MemoryRouter>
|
||||
<AuthenticationProvider signInPage={SignInPage}>
|
||||
{children}
|
||||
</AuthenticationProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export class ExtensionTester {
|
||||
/** @internal */
|
||||
static forSubject<TConfig>(
|
||||
subject: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
const tester = new ExtensionTester();
|
||||
const { output, factory, ...rest } = toInternalExtensionDefinition(subject);
|
||||
// attaching to core/routes to render as index route
|
||||
const extension = createExtension({
|
||||
...rest,
|
||||
attachTo: { id: 'core/routes', input: 'routes' },
|
||||
output: {
|
||||
...output,
|
||||
path: coreExtensionData.routePath,
|
||||
},
|
||||
factory: params => ({
|
||||
...factory(params),
|
||||
path: '/',
|
||||
}),
|
||||
});
|
||||
tester.add(extension, options);
|
||||
return tester;
|
||||
}
|
||||
|
||||
readonly #extensions = new Array<{
|
||||
id: string;
|
||||
definition: ExtensionDefinition<any>;
|
||||
config?: JsonValue;
|
||||
}>();
|
||||
|
||||
add<TConfig>(
|
||||
extension: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
const { name, namespace } = extension;
|
||||
|
||||
const definition = {
|
||||
...extension,
|
||||
// setting name "test" as fallback
|
||||
name: !namespace && !name ? 'test' : name,
|
||||
};
|
||||
|
||||
const { id } = resolveExtensionDefinition(definition);
|
||||
|
||||
this.#extensions.push({
|
||||
id,
|
||||
definition,
|
||||
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(extension => ({
|
||||
[extension.id]: {
|
||||
config: extension.config,
|
||||
},
|
||||
})),
|
||||
{
|
||||
[subject.id]: {
|
||||
config: subject.config,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const finalConfig = {
|
||||
...config,
|
||||
app: {
|
||||
...(typeof config.app === 'object' ? config.app : undefined),
|
||||
extensions: extensionsConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const app = createSpecializedApp({
|
||||
features: [
|
||||
createExtensionOverrides({
|
||||
extensions: [
|
||||
...this.#extensions.map(extension => extension.definition),
|
||||
TestCoreNavExtension,
|
||||
TestCoreRouterExtension,
|
||||
],
|
||||
}),
|
||||
],
|
||||
config: new MockConfigApi(finalConfig),
|
||||
});
|
||||
|
||||
return render(app.createRoot());
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createExtensionTester<TConfig>(
|
||||
subject: ExtensionDefinition<TConfig>,
|
||||
options?: { config?: TConfig },
|
||||
): ExtensionTester {
|
||||
return ExtensionTester.forSubject(subject, options);
|
||||
}
|
||||
@@ -14,14 +14,51 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
MockAnalyticsApi,
|
||||
TestApiProvider,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
import { analyticsApiRef, useAnalytics } from '@backstage/frontend-plugin-api';
|
||||
import { renderInTestApp } from './renderInTestApp';
|
||||
|
||||
describe('renderInTestApp', () => {
|
||||
it('should render the given component in a page', async () => {
|
||||
const Component = () => <div>Test</div>;
|
||||
renderInTestApp(<Component />);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
const IndexPage = () => <div>Index Page</div>;
|
||||
renderInTestApp(<IndexPage />);
|
||||
expect(screen.getByText('Index Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should works with apis provider', async () => {
|
||||
const IndexPage = () => {
|
||||
const analyticsApi = useAnalytics();
|
||||
const handleClick = useCallback(() => {
|
||||
analyticsApi.captureEvent('click', 'See details');
|
||||
}, [analyticsApi]);
|
||||
return (
|
||||
<div>
|
||||
Index Page
|
||||
<a href="/details" onClick={handleClick}>
|
||||
See details
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
|
||||
renderInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<IndexPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
|
||||
|
||||
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
subject: 'See details',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4233,9 +4233,11 @@ __metadata:
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@types/react": "*"
|
||||
peerDependencies:
|
||||
"@testing-library/react": ^12.1.3 || ^13.0.0 || ^14.0.0
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0
|
||||
react-router-dom: 6.0.0-beta.0 || ^6.3.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
||||
Reference in New Issue
Block a user