Add createTestEntityPage utility for testing entity extensions
This adds a test utility that simplifies testing entity cards and content extensions in the new frontend system. The utility creates a test page that provides EntityProvider context and accepts entity extensions through input redirects. Also adds the `apis` option to `renderTestApp` for API overrides, and includes tests for entity cards in catalog, org, and api-docs plugins. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
'@backstage/plugin-catalog-graph': patch
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Added `@backstage/frontend-test-utils` dev dependency.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': minor
|
||||
---
|
||||
|
||||
Added `createTestEntityPage` test utility for testing entity cards and content extensions in the new frontend system. This utility creates a test page extension that provides `EntityProvider` context and accepts entity extensions through input redirects:
|
||||
|
||||
```typescript
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity: myEntity }), myEntityCard],
|
||||
});
|
||||
```
|
||||
@@ -200,6 +200,38 @@ describe('Index page', () => {
|
||||
});
|
||||
```
|
||||
|
||||
## Testing entity extensions
|
||||
|
||||
The `createTestEntityPage` utility from `@backstage/plugin-catalog-react/testUtils` simplifies testing entity cards and content extensions. It creates a test page that mounts at `/`, provides an `EntityProvider` context, and picks up entity extensions through input redirects.
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { myEntityCard } from './plugin';
|
||||
|
||||
describe('MyEntityCard', () => {
|
||||
it('should render for Component entities', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'my-service' },
|
||||
spec: { type: 'service', owner: 'team-a' },
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), myEntityCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My Card Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Entity content extensions can be tested the exact same way, just pass your content extension instead of a card. The test page also supports entity filters defined on the extensions, so you can test filter behavior by providing different entity kinds. If your extension depends on APIs you can pass mock implementation using the `apis` option `renderTestApp`, or you can pass the API extension directly alongside your content extension.
|
||||
|
||||
Extensions that use `EntityRefLinks` or `useRelatedEntities` may require additional API mocking using the `apis` option on `renderTestApp`.
|
||||
|
||||
## Mounting routes
|
||||
|
||||
If your component or extension uses `useRouteRef` to generate links to other routes, you need to mount those routes in the test environment. Both `renderInTestApp` and `renderTestApp` support the `mountedRoutes` option for this purpose.
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { ApiEntity, ComponentEntity } from '@backstage/catalog-model';
|
||||
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import apiDocsPlugin from './alpha';
|
||||
|
||||
const apiDocsDefinitionEntityCard = apiDocsPlugin.getExtension(
|
||||
'entity-card:api-docs/definition',
|
||||
);
|
||||
const apiDocsDefinitionEntityContent = apiDocsPlugin.getExtension(
|
||||
'entity-content:api-docs/definition',
|
||||
);
|
||||
const apiDocsConfigApi = apiDocsPlugin.getExtension('api:api-docs/config');
|
||||
|
||||
describe('api-docs plugin entity extensions', () => {
|
||||
describe('apiDocsDefinitionEntityCard', () => {
|
||||
it('should render for API entities', async () => {
|
||||
const entity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-api',
|
||||
title: 'My API',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
definition:
|
||||
'openapi: 3.0.0\ninfo:\n title: My API\n version: 1.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
apiDocsConfigApi,
|
||||
apiDocsDefinitionEntityCard,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My API')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for non-API entities', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
apiDocsConfigApi,
|
||||
apiDocsDefinitionEntityCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for page to render, then check definition card is not shown
|
||||
expect(await screen.findByText('my-component')).toBeInTheDocument();
|
||||
expect(screen.queryByText('openapi')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the API definition content', async () => {
|
||||
const entity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'pet-api',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
definition: JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Pet Store API', version: '1.0.0' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
apiDocsConfigApi,
|
||||
apiDocsDefinitionEntityCard,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('pet-api')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiDocsDefinitionEntityContent', () => {
|
||||
it('should render for API entities', async () => {
|
||||
const entity: ApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'content-api',
|
||||
title: 'Content API',
|
||||
},
|
||||
spec: {
|
||||
type: 'graphql',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-b',
|
||||
definition: 'type Query { hello: String }',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
apiDocsConfigApi,
|
||||
apiDocsDefinitionEntityContent,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Content API')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for non-API entities', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-service',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
apiDocsConfigApi,
|
||||
apiDocsDefinitionEntityContent,
|
||||
],
|
||||
});
|
||||
|
||||
// Content should not render for Components
|
||||
expect(await screen.findByText('my-service')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Definition')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { ComponentEntity, Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
createTestEntityPage,
|
||||
catalogApiMock,
|
||||
} from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import catalogGraphPlugin from './alpha';
|
||||
import { catalogGraphRouteRef } from './routes';
|
||||
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
|
||||
|
||||
const CatalogGraphEntityCard = catalogGraphPlugin.getExtension(
|
||||
'entity-card:catalog-graph/relations',
|
||||
);
|
||||
const CatalogGraphApi = catalogGraphPlugin.getExtension('api:catalog-graph');
|
||||
|
||||
describe('catalog-graph alpha plugin', () => {
|
||||
describe('CatalogGraphEntityCard', () => {
|
||||
it('should render with Relations title', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-service',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
CatalogGraphApi,
|
||||
CatalogGraphEntityCard,
|
||||
],
|
||||
mountedRoutes: {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Relations')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('component:default/my-service'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render entity node in the graph for any entity kind', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-api',
|
||||
namespace: 'production',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-b',
|
||||
definition: '{}',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
CatalogGraphApi,
|
||||
CatalogGraphEntityCard,
|
||||
],
|
||||
mountedRoutes: {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Relations')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('api:production/my-api'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the View graph link', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test-component',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
CatalogGraphApi,
|
||||
CatalogGraphEntityCard,
|
||||
],
|
||||
mountedRoutes: {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('View graph')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { DefaultEntityFilters } from '@backstage/plugin-catalog-react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityListContextProps } from '@backstage/plugin-catalog-react';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
@@ -25,6 +26,11 @@ export namespace catalogApiMock {
|
||||
) => ApiMock<CatalogApi>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function createTestEntityPage(
|
||||
options: TestEntityPageOptions,
|
||||
): ExtensionDefinition;
|
||||
|
||||
// @public
|
||||
export function MockEntityListContextProvider<
|
||||
T extends DefaultEntityFilters = DefaultEntityFilters,
|
||||
@@ -33,4 +39,9 @@ export function MockEntityListContextProvider<
|
||||
value?: Partial<EntityListContextProps<T>>;
|
||||
}>,
|
||||
): JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface TestEntityPageOptions {
|
||||
entity: Entity;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -22,3 +22,7 @@
|
||||
|
||||
export { catalogApiMock } from './testUtils/catalogApiMock';
|
||||
export { MockEntityListContextProvider } from './testUtils/MockEntityListContextProvider';
|
||||
export {
|
||||
createTestEntityPage,
|
||||
type TestEntityPageOptions,
|
||||
} from './testUtils/createTestEntityPage';
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { createTestEntityPage } from './createTestEntityPage';
|
||||
import { catalogApiMock } from './catalogApiMock';
|
||||
import {
|
||||
EntityCardBlueprint,
|
||||
EntityContentBlueprint,
|
||||
} from '../alpha/blueprints';
|
||||
import { catalogApiRef } from '../api';
|
||||
|
||||
const mockEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test-component',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
describe('createTestEntityPage', () => {
|
||||
describe('entity cards', () => {
|
||||
it('should render a card for a matching entity', async () => {
|
||||
const testCard = EntityCardBlueprint.make({
|
||||
name: 'test-card',
|
||||
params: {
|
||||
loader: async () => <div>Test Card Content</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity: mockEntity }), testCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Test Card Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter cards based on entity predicate', async () => {
|
||||
const apiOnlyCard = EntityCardBlueprint.make({
|
||||
name: 'api-card',
|
||||
params: {
|
||||
loader: async () => <div>API Card</div>,
|
||||
filter: entity => entity.kind === 'API',
|
||||
},
|
||||
});
|
||||
|
||||
const componentCard = EntityCardBlueprint.make({
|
||||
name: 'component-card',
|
||||
params: {
|
||||
loader: async () => <div>Component Card</div>,
|
||||
filter: entity => entity.kind === 'Component',
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
apiOnlyCard,
|
||||
componentCard,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Component Card')).toBeInTheDocument();
|
||||
expect(screen.queryByText('API Card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter cards based on predicate config', async () => {
|
||||
const kindCard = EntityCardBlueprint.make({
|
||||
name: 'kind-card',
|
||||
params: {
|
||||
loader: async () => <div>Kind Card</div>,
|
||||
filter: { kind: 'API' },
|
||||
},
|
||||
});
|
||||
|
||||
const sentinelCard = EntityCardBlueprint.make({
|
||||
name: 'sentinel-card',
|
||||
params: {
|
||||
loader: async () => <div>Sentinel</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
kindCard,
|
||||
sentinelCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for rendering to complete via sentinel
|
||||
await screen.findByText('Sentinel');
|
||||
expect(screen.queryByText('Kind Card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple cards', async () => {
|
||||
const card1 = EntityCardBlueprint.make({
|
||||
name: 'card-1',
|
||||
params: {
|
||||
loader: async () => <div>Card 1</div>,
|
||||
},
|
||||
});
|
||||
|
||||
const card2 = EntityCardBlueprint.make({
|
||||
name: 'card-2',
|
||||
params: {
|
||||
loader: async () => <div>Card 2</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
card1,
|
||||
card2,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Card 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Card 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('entity content', () => {
|
||||
it('should render a single content extension at root path', async () => {
|
||||
const testContent = EntityContentBlueprint.make({
|
||||
name: 'test-content',
|
||||
params: {
|
||||
path: '/details',
|
||||
title: 'Details',
|
||||
loader: async () => <div>Test Content</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity: mockEntity }), testContent],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Test Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter content based on entity predicate', async () => {
|
||||
const apiContent = EntityContentBlueprint.make({
|
||||
name: 'api-content',
|
||||
params: {
|
||||
path: '/api-details',
|
||||
title: 'API Details',
|
||||
loader: async () => <div>API Content</div>,
|
||||
filter: entity => entity.kind === 'API',
|
||||
},
|
||||
});
|
||||
|
||||
const sentinelCard = EntityCardBlueprint.make({
|
||||
name: 'sentinel-card',
|
||||
params: {
|
||||
loader: async () => <div>Sentinel</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
apiContent,
|
||||
sentinelCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for rendering to complete via sentinel
|
||||
await screen.findByText('Sentinel');
|
||||
expect(screen.queryByText('API Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple content extensions with routing', async () => {
|
||||
const contentA = EntityContentBlueprint.make({
|
||||
name: 'content-a',
|
||||
params: {
|
||||
path: '/a',
|
||||
title: 'Content A',
|
||||
loader: async () => <div>Content A</div>,
|
||||
},
|
||||
});
|
||||
|
||||
const contentB = EntityContentBlueprint.make({
|
||||
name: 'content-b',
|
||||
params: {
|
||||
path: '/b',
|
||||
title: 'Content B',
|
||||
loader: async () => <div>Content B</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
contentA,
|
||||
contentB,
|
||||
],
|
||||
});
|
||||
|
||||
// First content should be rendered at root by default
|
||||
expect(await screen.findByText('Content A')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined usage', () => {
|
||||
it('should render cards and content together', async () => {
|
||||
const testCard = EntityCardBlueprint.make({
|
||||
name: 'combined-card',
|
||||
params: {
|
||||
loader: async () => <div>Combined Card</div>,
|
||||
},
|
||||
});
|
||||
|
||||
const testContent = EntityContentBlueprint.make({
|
||||
name: 'combined-content',
|
||||
params: {
|
||||
path: '/combined',
|
||||
title: 'Combined',
|
||||
loader: async () => <div>Combined Content</div>,
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
testCard,
|
||||
testContent,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Combined Card')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Combined Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with different entity kinds', async () => {
|
||||
const apiEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'test-api' },
|
||||
spec: { type: 'openapi' },
|
||||
};
|
||||
|
||||
const apiCard = EntityCardBlueprint.make({
|
||||
name: 'api-specific-card',
|
||||
params: {
|
||||
loader: async () => <div>API Card</div>,
|
||||
filter: entity => entity.kind === 'API',
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity: apiEntity }), apiCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('API Card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide entity context to cards', async () => {
|
||||
const entityNameCard = EntityCardBlueprint.make({
|
||||
name: 'entity-name-card',
|
||||
params: {
|
||||
loader: async () => {
|
||||
const { useEntity } = await import('../hooks/useEntity');
|
||||
const EntityNameDisplay = () => {
|
||||
const { entity } = useEntity();
|
||||
return <div>Entity: {entity.metadata.name}</div>;
|
||||
};
|
||||
return <EntityNameDisplay />;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
entityNameCard,
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Entity: test-component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog API', () => {
|
||||
it('should provide a mock catalog API with the entity', async () => {
|
||||
const catalogApiCard = EntityCardBlueprint.make({
|
||||
name: 'catalog-api-card',
|
||||
params: {
|
||||
loader: async () => {
|
||||
const { useApi } = await import('@backstage/core-plugin-api');
|
||||
const { catalogApiRef: ref } = await import('../api');
|
||||
const React = await import('react');
|
||||
const CatalogApiDisplay = () => {
|
||||
const catalogApi = useApi(ref);
|
||||
const [entities, setEntities] = React.useState<string[]>([]);
|
||||
React.useEffect(() => {
|
||||
catalogApi
|
||||
.getEntities()
|
||||
.then(response =>
|
||||
setEntities(response.items.map(e => e.metadata.name)),
|
||||
);
|
||||
}, [catalogApi]);
|
||||
return <div>Entities: {entities.join(', ') || 'loading'}</div>;
|
||||
};
|
||||
return <CatalogApiDisplay />;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
catalogApiCard,
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Entities: test-component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow user-provided catalog API to take precedence', async () => {
|
||||
const customEntities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'custom-a', namespace: 'default' },
|
||||
spec: { type: 'service' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'custom-b', namespace: 'default' },
|
||||
spec: { type: 'service' },
|
||||
},
|
||||
];
|
||||
|
||||
const catalogApiCard = EntityCardBlueprint.make({
|
||||
name: 'catalog-api-card',
|
||||
params: {
|
||||
loader: async () => {
|
||||
const { useApi } = await import('@backstage/core-plugin-api');
|
||||
const { catalogApiRef: ref } = await import('../api');
|
||||
const React = await import('react');
|
||||
const CatalogApiDisplay = () => {
|
||||
const catalogApi = useApi(ref);
|
||||
const [entities, setEntities] = React.useState<string[]>([]);
|
||||
React.useEffect(() => {
|
||||
catalogApi
|
||||
.getEntities()
|
||||
.then(response =>
|
||||
setEntities(response.items.map(e => e.metadata.name)),
|
||||
);
|
||||
}, [catalogApi]);
|
||||
return <div>Entities: {entities.join(', ') || 'loading'}</div>;
|
||||
};
|
||||
return <CatalogApiDisplay />;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
catalogApiCard,
|
||||
],
|
||||
apis: [[catalogApiRef, catalogApiMock({ entities: customEntities })]],
|
||||
});
|
||||
|
||||
// Should use the user-provided catalog API with custom entities, not mockEntity
|
||||
expect(
|
||||
await screen.findByText('Entities: custom-a, custom-b'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
coreExtensionData,
|
||||
createExtensionInput,
|
||||
PageBlueprint,
|
||||
ExtensionDefinition,
|
||||
useApiHolder,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { EntityProvider } from '../hooks/useEntity';
|
||||
import {
|
||||
EntityCardBlueprint,
|
||||
EntityContentBlueprint,
|
||||
} from '../alpha/blueprints';
|
||||
import { entityRouteRef } from '../routes';
|
||||
import { catalogApiRef } from '../api';
|
||||
import { catalogApiMock } from './catalogApiMock';
|
||||
import { TestApiProvider } from '@backstage/frontend-test-utils';
|
||||
|
||||
/**
|
||||
* Options for creating a test entity page.
|
||||
* @public
|
||||
*/
|
||||
export interface TestEntityPageOptions {
|
||||
/**
|
||||
* The entity to provide in the EntityProvider context.
|
||||
*/
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test entity page extension that provides EntityProvider context
|
||||
* and accepts entity extensions (cards, content) as inputs.
|
||||
*
|
||||
* This utility simplifies testing entity extensions by eliminating the need
|
||||
* for manual EntityProvider wrapping and route configuration.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The test page uses input redirects to accept both:
|
||||
* - Entity cards (normally attach to `entity-content:catalog/overview`)
|
||||
* - Entity content (normally attach to `page:catalog/entity`)
|
||||
*
|
||||
* Cards are rendered in a simple container. Content extensions follow
|
||||
* standard routing behavior, with a single content optimization that
|
||||
* renders directly without routing.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
* import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
*
|
||||
* const mockEntity = {
|
||||
* apiVersion: 'backstage.io/v1alpha1',
|
||||
* kind: 'Component',
|
||||
* metadata: { name: 'test' },
|
||||
* spec: { type: 'service' },
|
||||
* };
|
||||
*
|
||||
* // Testing an entity card
|
||||
* renderTestApp({
|
||||
* extensions: [createTestEntityPage({ entity: mockEntity }), myEntityCard],
|
||||
* });
|
||||
*
|
||||
* // Testing entity content
|
||||
* renderTestApp({
|
||||
* extensions: [createTestEntityPage({ entity: mockEntity }), myEntityContent],
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createTestEntityPage(
|
||||
options: TestEntityPageOptions,
|
||||
): ExtensionDefinition {
|
||||
const { entity } = options;
|
||||
|
||||
return PageBlueprint.makeWithOverrides({
|
||||
name: 'test-entity',
|
||||
inputs: {
|
||||
// Redirect cards from entity-content:catalog/overview
|
||||
cards: createExtensionInput(
|
||||
[
|
||||
coreExtensionData.reactElement,
|
||||
EntityContentBlueprint.dataRefs.filterFunction.optional(),
|
||||
EntityContentBlueprint.dataRefs.filterExpression.optional(),
|
||||
EntityCardBlueprint.dataRefs.type.optional(),
|
||||
],
|
||||
{
|
||||
replaces: [{ id: 'entity-content:catalog/overview', input: 'cards' }],
|
||||
},
|
||||
),
|
||||
// Redirect contents from page:catalog/entity
|
||||
contents: createExtensionInput(
|
||||
[
|
||||
coreExtensionData.reactElement,
|
||||
coreExtensionData.routePath,
|
||||
coreExtensionData.routeRef.optional(),
|
||||
EntityContentBlueprint.dataRefs.title,
|
||||
EntityContentBlueprint.dataRefs.filterFunction.optional(),
|
||||
EntityContentBlueprint.dataRefs.filterExpression.optional(),
|
||||
EntityContentBlueprint.dataRefs.group.optional(),
|
||||
],
|
||||
{
|
||||
replaces: [{ id: 'page:catalog/entity', input: 'contents' }],
|
||||
},
|
||||
),
|
||||
},
|
||||
factory: (originalFactory, { inputs }) => {
|
||||
return originalFactory({
|
||||
path: '/',
|
||||
routeRef: entityRouteRef,
|
||||
loader: async () => {
|
||||
// Process cards with entity filtering
|
||||
const cards = inputs.cards
|
||||
.filter(card =>
|
||||
(
|
||||
card.get(EntityContentBlueprint.dataRefs.filterFunction) ??
|
||||
(() => true)
|
||||
)(entity),
|
||||
)
|
||||
.map(card => ({
|
||||
element: card.get(coreExtensionData.reactElement),
|
||||
type: card.get(EntityCardBlueprint.dataRefs.type),
|
||||
}));
|
||||
|
||||
// Process contents with entity filtering
|
||||
const contents = inputs.contents
|
||||
.filter(content =>
|
||||
(
|
||||
content.get(EntityContentBlueprint.dataRefs.filterFunction) ??
|
||||
(() => true)
|
||||
)(entity),
|
||||
)
|
||||
.map(content => ({
|
||||
element: content.get(coreExtensionData.reactElement),
|
||||
path: content.get(coreExtensionData.routePath),
|
||||
title: content.get(EntityContentBlueprint.dataRefs.title),
|
||||
}));
|
||||
|
||||
return (
|
||||
<MockEntityApiProvider entity={entity}>
|
||||
<EntityProvider entity={entity}>
|
||||
{cards.map((card, index) => (
|
||||
<Fragment key={index}>{card.element}</Fragment>
|
||||
))}
|
||||
{contents.length === 1 && contents[0].element}
|
||||
{contents.length > 1 && (
|
||||
<Routes>
|
||||
{contents.map(content => (
|
||||
<Route
|
||||
key={content.path}
|
||||
path={
|
||||
content.path === '/'
|
||||
? '/'
|
||||
: `${content.path.replace(/^\//, '')}/*`
|
||||
}
|
||||
element={content.element}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={contents[0].element} />
|
||||
</Routes>
|
||||
)}
|
||||
</EntityProvider>
|
||||
</MockEntityApiProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock provider that injects a catalog API with the single entity of the test entity page.
|
||||
*
|
||||
* Users can still install their own catalog API implementations and they will take precedence over the mock.
|
||||
* @internal
|
||||
*/
|
||||
function MockEntityApiProvider({
|
||||
entity,
|
||||
children,
|
||||
}: {
|
||||
entity: Entity;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const parentHolder = useApiHolder();
|
||||
|
||||
// If catalog API is already provided, don't override it
|
||||
if (parentHolder.get(catalogApiRef)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Provide a mock catalog API with the single entity
|
||||
const mockApi = catalogApiMock({ entities: [entity] });
|
||||
return (
|
||||
<TestApiProvider apis={[[catalogApiRef, mockApi]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogLinksEntityCard, catalogLabelsEntityCard } from './entityCards';
|
||||
|
||||
describe('catalog entity cards', () => {
|
||||
describe('catalogLinksEntityCard', () => {
|
||||
it('should render for entities with links', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
links: [{ url: 'https://example.com', title: 'Example' }],
|
||||
},
|
||||
spec: { type: 'service' },
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), catalogLinksEntityCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Links')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Example')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for entities without links', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
labels: { team: 'platform' },
|
||||
},
|
||||
spec: { type: 'service' },
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
catalogLinksEntityCard,
|
||||
catalogLabelsEntityCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Labels card renders as sentinel
|
||||
expect(await screen.findByText('Labels')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Links')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogLabelsEntityCard', () => {
|
||||
it('should render for entities with labels', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
labels: { team: 'platform' },
|
||||
},
|
||||
spec: { type: 'service' },
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), catalogLabelsEntityCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Labels')).toBeInTheDocument();
|
||||
expect(await screen.findByText('team')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for entities without labels', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
links: [{ url: 'https://example.com', title: 'Example' }],
|
||||
},
|
||||
spec: { type: 'service' },
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
catalogLinksEntityCard,
|
||||
catalogLabelsEntityCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Links card renders as sentinel
|
||||
expect(await screen.findByText('Links')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
createTestEntityPage,
|
||||
catalogApiMock,
|
||||
} from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import orgPlugin from './alpha';
|
||||
|
||||
const EntityGroupProfileCard = orgPlugin.getExtension(
|
||||
'entity-card:org/group-profile',
|
||||
);
|
||||
const EntityUserProfileCard = orgPlugin.getExtension(
|
||||
'entity-card:org/user-profile',
|
||||
);
|
||||
const EntityMembersListCard = orgPlugin.getExtension(
|
||||
'entity-card:org/members-list',
|
||||
);
|
||||
|
||||
describe('org plugin entity cards', () => {
|
||||
describe('EntityGroupProfileCard', () => {
|
||||
it('should render for Group entities', async () => {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'platform-team',
|
||||
description: 'The platform team',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), EntityGroupProfileCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('platform-team')).toBeInTheDocument();
|
||||
expect(await screen.findByText('The platform team')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for non-Group entities', async () => {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'test-user',
|
||||
},
|
||||
spec: {
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
EntityGroupProfileCard,
|
||||
EntityUserProfileCard,
|
||||
],
|
||||
});
|
||||
|
||||
// User profile card renders as sentinel
|
||||
expect(await screen.findByText('test-user')).toBeInTheDocument();
|
||||
// Group profile card should not render (no group-specific elements)
|
||||
expect(
|
||||
screen.queryByText('group:default/test-user'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display group profile with email', async () => {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'platform-team',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
profile: {
|
||||
displayName: 'Platform Team',
|
||||
email: 'platform@example.com',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), EntityGroupProfileCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Platform Team')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('platform@example.com'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityUserProfileCard', () => {
|
||||
it('should render for User entities', async () => {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'jdoe',
|
||||
description: 'Software Engineer',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'John Doe',
|
||||
email: 'jdoe@example.com',
|
||||
},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), EntityUserProfileCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('John Doe')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Software Engineer')).toBeInTheDocument();
|
||||
expect(await screen.findByText('jdoe@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render for non-User entities', async () => {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'platform-team',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity }),
|
||||
EntityGroupProfileCard,
|
||||
EntityUserProfileCard,
|
||||
],
|
||||
});
|
||||
|
||||
// Group profile card renders as sentinel
|
||||
expect(await screen.findByText('platform-team')).toBeInTheDocument();
|
||||
// Check that user-specific text doesn't appear
|
||||
expect(screen.queryByText('jdoe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fall back to metadata name when no display name', async () => {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'jdoe',
|
||||
},
|
||||
spec: {
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), EntityUserProfileCard],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('jdoe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityMembersListCard', () => {
|
||||
it('should show members when catalog API returns them', async () => {
|
||||
const groupEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'platform-team',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
const memberUser: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'alice',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
memberOf: ['group:default/platform-team'],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'memberOf',
|
||||
targetRef: 'group:default/platform-team',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: groupEntity }),
|
||||
EntityMembersListCard,
|
||||
],
|
||||
apis: [
|
||||
[
|
||||
catalogApiRef,
|
||||
catalogApiMock({ entities: [groupEntity, memberUser] }),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(await screen.findByText('alice@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show no members message when catalog API returns empty', async () => {
|
||||
const groupEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'empty-team',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderTestApp({
|
||||
extensions: [
|
||||
createTestEntityPage({ entity: groupEntity }),
|
||||
EntityMembersListCard,
|
||||
],
|
||||
apis: [[catalogApiRef, catalogApiMock({ entities: [groupEntity] })]],
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('This group has no members.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4081,6 +4081,7 @@ __metadata:
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-common": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
@@ -5277,6 +5278,7 @@ __metadata:
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
@@ -6412,6 +6414,7 @@ __metadata:
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-common": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user