From 4158ef3301f88b40ca10f75759d67200ab8c216d Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 9 Dec 2023 12:25:19 +0100 Subject: [PATCH] fix(frontend-test-utils): enable testing more than one route Signed-off-by: Camila Belo --- packages/frontend-test-utils/package.json | 4 +- .../src/app/createExtensionTester.test.tsx | 226 +++++++++++++++--- .../src/app/createExtensionTester.ts | 52 ++-- .../src/app/renderInTestApp.test.tsx | 49 +++- yarn.lock | 2 + 5 files changed, 274 insertions(+), 59 deletions(-) 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 ( +
+ Index Page + + See details + +
+ ); + }; + + 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