From 67409cbfe5d03d5f845c8c2097a6947a766183db Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Dec 2023 16:43:24 +0100 Subject: [PATCH] refactor(frontend-test-utils): simplying core nav and router for test apps Signed-off-by: Camila Belo --- .../src/wiring/createApp.test.tsx | 19 ++ packages/frontend-test-utils/package.json | 3 +- .../src/app/createExtensionTester.test.tsx | 6 +- .../src/app/createExtensionTester.ts | 136 -------- .../src/app/createExtensionTester.tsx | 301 ++++++++++++++++++ .../src/app/renderInTestApp.test.tsx | 4 +- yarn.lock | 1 + 7 files changed, 327 insertions(+), 143 deletions(-) delete mode 100644 packages/frontend-test-utils/src/app/createExtensionTester.ts create mode 100644 packages/frontend-test-utils/src/app/createExtensionTester.tsx diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index dafad5365c..338d293132 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -210,6 +210,25 @@ describe('createApp', () => { ] + apis [ + + + + + + + + + + + + + + + + + + ] " `); }); diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 9ca546721f..33b1f41a8c 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -39,6 +39,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" } } diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index bdcefb7b8b..de52ca52de 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -103,7 +103,7 @@ describe('createExtensionTester', () => { ).resolves.toBeInTheDocument(); }); - it.skip('should accepts a custom config', async () => { + it('should accepts a custom config', async () => { const indexPageExtension = createExtension({ ...defaultDefinition, configSchema: createSchemaFromZod(z => @@ -175,7 +175,7 @@ describe('createExtensionTester', () => { ).resolves.toBeInTheDocument(); }); - it.skip('should capture click events in anylitics', async () => { + it('should capture click events in anylitics', async () => { // Mocking the analytics api implementation const analyticsApiMock = new MockAnalyticsApi(); @@ -219,7 +219,7 @@ describe('createExtensionTester', () => { fireEvent.click(await screen.findByRole('button', { name: 'See details' })); await waitFor(() => - expect(analyticsApiMock.getEvents()[1]).toMatchObject({ + expect(analyticsApiMock.getEvents()[0]).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 deleted file mode 100644 index 18c3c84ec9..0000000000 --- a/packages/frontend-test-utils/src/app/createExtensionTester.ts +++ /dev/null @@ -1,136 +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, - coreExtensionData, - createExtension, - 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( - subject: ExtensionDefinition, - options?: { config?: TConfig }, - ): ExtensionTester { - const tester = new ExtensionTester(); - 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; - definition: ExtensionDefinition; - config?: JsonValue; - }>(); - - add( - extension: ExtensionDefinition, - 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), - }), - ], - config: new MockConfigApi(finalConfig), - }); - - return render(app.createRoot()); - } -} - -/** @public */ -export function createExtensionTester( - subject: ExtensionDefinition, - options?: { config?: TConfig }, -): ExtensionTester { - return ExtensionTester.forSubject(subject, options); -} diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx new file mode 100644 index 0000000000..0404cf1ade --- /dev/null +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -0,0 +1,301 @@ +/* + * 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 } from 'react-router-dom'; +import { Link } from '@backstage/core-components'; +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 { InternalExtensionDefinition } 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; + title: string; + icon: IconComponent; +}) => { + const { routeRef, title, icon: Icon } = props; + const to = useRouteRef(routeRef)(); + return ( +
  • + + {title} + +
  • + ); +}; + +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: ( + + ), + }; + }, +}); + +const AuthenticationProvider = (props: { + signInPage?: ComponentType; + 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(); + + 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 ; + } + + 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: ( + + + {children} + + + ), + }; + }, +}); + +/** @public */ +export class ExtensionTester { + /** @internal */ + static forSubject( + subject: ExtensionDefinition, + options?: { config?: TConfig }, + ): ExtensionTester { + const tester = new ExtensionTester(); + const { output, factory, ...rest } = + subject as InternalExtensionDefinition; + // 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; + config?: JsonValue; + }>(); + + add( + extension: ExtensionDefinition, + 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( + subject: ExtensionDefinition, + options?: { config?: TConfig }, +): ExtensionTester { + return ExtensionTester.forSubject(subject, options); +} diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index d51f66010c..a6783d43a3 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -56,9 +56,7 @@ describe('renderInTestApp', () => { fireEvent.click(screen.getByRole('link', { name: 'See details' })); - const events = analyticsApiMock.getEvents(); - - expect(events[1]).toMatchObject({ + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ action: 'click', subject: 'See details', }); diff --git a/yarn.lock b/yarn.lock index 8689a6ad0d..910432180d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4237,6 +4237,7 @@ __metadata: 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