diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json
index cdf2bc14a0..9ca546721f 100644
--- a/packages/frontend-test-utils/package.json
+++ b/packages/frontend-test-utils/package.json
@@ -24,12 +24,14 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
- "@testing-library/jest-dom": "^6.0.0"
+ "@testing-library/jest-dom": "^6.0.0",
+ "@types/react": "*"
},
"files": [
"dist"
],
"dependencies": {
+ "@backstage/core-components": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/test-utils": "workspace:^",
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index e738bee7d1..bdcefb7b8b 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -14,55 +14,215 @@
* limitations under the License.
*/
+import React, { useCallback } from 'react';
+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 { Link } from '@backstage/core-components';
+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:
test
}),
- }),
- ).render();
+ const defaultDefinition = {
+ namespace: 'test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: { element: coreExtensionData.reactElement },
+ factory: () => ({ element: test
}),
+ };
+ 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: test
}),
- }),
- ).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: (
+
+ Index page See details
+
+ ),
+ }),
+ });
+ const detailsPageExtension = createExtension({
+ ...defaultDefinition,
+ name: 'details',
+ attachTo: { id: 'core/routes', input: 'routes' },
+ output: {
+ path: coreExtensionData.routePath,
+ element: coreExtensionData.reactElement,
+ },
+ factory: () => ({ path: '/details', element: Details page
}),
+ });
+
+ 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.skip('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 (
+
+
{appTitle ?? 'Backstafe app'}
+ {config.title ?? 'Index page'}
+ See details
+
+ );
+ };
+ return {
+ element: ,
+ };
+ },
+ });
+
+ 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: {config.title ?? 'Details page'}
,
+ }),
+ });
+
+ 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.skip('should capture click events in anylitics', 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 (
+
+ Index Page
+
+
+ );
+ };
+
+ return {
+ element: ,
+ };
+ },
+ });
+
+ 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()[1]).toMatchObject({
+ action: 'click',
+ subject: 'See details',
+ }),
);
});
});
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts
index 257a47439c..18c3c84ec9 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.ts
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.ts
@@ -17,6 +17,8 @@
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDefinition,
+ coreExtensionData,
+ createExtension,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -33,13 +35,27 @@ export class ExtensionTester {
options?: { config?: TConfig },
): ExtensionTester {
const tester = new ExtensionTester();
- tester.add(subject, options);
+ const { output, factory, ...rest } = 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;
- extension: ExtensionDefinition;
+ definition: ExtensionDefinition;
config?: JsonValue;
}>();
@@ -47,13 +63,19 @@ export class ExtensionTester {
extension: ExtensionDefinition,
options?: { config?: TConfig },
): ExtensionTester {
- const withNamespace = {
+ const { name, namespace } = extension;
+
+ const definition = {
...extension,
- name: !extension.namespace && !extension.name ? 'test' : extension.name,
+ // setting name "test" as fallback
+ name: !namespace && !name ? 'test' : name,
};
+
+ const { id } = resolveExtensionDefinition(definition);
+
this.#extensions.push({
- id: resolveExtensionDefinition(withNamespace).id,
- extension: withNamespace,
+ id,
+ definition,
config: options?.config as JsonValue,
});
@@ -71,27 +93,17 @@ export class ExtensionTester {
}
const extensionsConfig: JsonArray = [
- ...rest.map(entry => ({
- [entry.id]: {
- config: entry.config,
+ ...rest.map(extension => ({
+ [extension.id]: {
+ config: extension.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 = {
@@ -105,7 +117,7 @@ export class ExtensionTester {
const app = createSpecializedApp({
features: [
createExtensionOverrides({
- extensions: this.#extensions.map(entry => entry.extension),
+ extensions: this.#extensions.map(extension => extension.definition),
}),
],
config: new MockConfigApi(finalConfig),
diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
index e13d1157ef..d51f66010c 100644
--- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
+++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
@@ -14,14 +14,53 @@
* 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 = () => Test
;
- renderInTestApp();
- expect(screen.getByText('Test')).toBeInTheDocument();
+ const IndexPage = () => Index Page
;
+ renderInTestApp();
+ 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 (
+
+ );
+ };
+
+ const analyticsApiMock = new MockAnalyticsApi();
+
+ renderInTestApp(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('link', { name: 'See details' }));
+
+ const events = analyticsApiMock.getEvents();
+
+ expect(events[1]).toMatchObject({
+ action: 'click',
+ subject: 'See details',
+ });
});
});
diff --git a/yarn.lock b/yarn.lock
index 8a7e8a1dac..8689a6ad0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4227,11 +4227,13 @@ __metadata:
resolution: "@backstage/frontend-test-utils@workspace:packages/frontend-test-utils"
dependencies:
"@backstage/cli": "workspace:^"
+ "@backstage/core-components": "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
+ "@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