/* * Copyright 2020 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, ReactElement } from 'react'; import { MemoryRouter, Routes } from 'react-router'; import { Route } from 'react-router-dom'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core/styles'; import { CssBaseline } from '@material-ui/core'; import MockIcon from '@material-ui/icons/AcUnit'; import { createSpecializedApp } from '@backstage/core-app-api'; import { BootErrorPageProps, RouteRef, ExternalRouteRef, attachComponentData, createRouteRef, } from '@backstage/core-plugin-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from './testingLibrary'; import { defaultApis } from './defaultApis'; import { mockApis } from './mockApis'; const mockIcons = { 'kind:api': MockIcon, 'kind:component': MockIcon, 'kind:domain': MockIcon, 'kind:group': MockIcon, 'kind:location': MockIcon, 'kind:system': MockIcon, 'kind:user': MockIcon, brokenImage: MockIcon, catalog: MockIcon, scaffolder: MockIcon, techdocs: MockIcon, search: MockIcon, chat: MockIcon, dashboard: MockIcon, docs: MockIcon, email: MockIcon, github: MockIcon, group: MockIcon, help: MockIcon, user: MockIcon, warning: MockIcon, }; const ErrorBoundaryFallback = ({ error }: { error: Error }) => { throw new Error(`Reached ErrorBoundaryFallback Page with error, ${error}`); }; const NotFoundErrorPage = () => { throw new Error('Reached NotFound Page'); }; const BootErrorPage = ({ step, error }: BootErrorPageProps) => { throw new Error(`Reached BootError Page at step ${step} with error ${error}`); }; const Progress = () => <div data-testid="progress" />; const NoRender = (_props: { children: ReactNode }) => null; /** * Options to customize the behavior of the test app wrapper. * @public */ export type TestAppOptions = { /** * Initial route entries to pass along as `initialEntries` to the router. */ routeEntries?: string[]; /** * An object of paths to mount route ref on, with the key being the path and the value * being the RouteRef that the path will be bound to. This allows the route refs to be * used by `useRouteRef` in the rendered elements. * * @example * wrapInTestApp(<MyComponent />, \{ * mountedRoutes: \{ * '/my-path': myRouteRef, * \} * \}) * // ... * const link = useRouteRef(myRouteRef) */ mountedRoutes?: { [path: string]: RouteRef | ExternalRouteRef }; }; function isExternalRouteRef( routeRef: RouteRef | ExternalRouteRef, ): routeRef is ExternalRouteRef { // TODO(Rugvip): Least ugly workaround for now, but replace :D return String(routeRef).includes('{type=external,'); } /** * Creates a Wrapper component that wraps a component inside a Backstage test app, * providing a mocked theme and app context, along with mocked APIs. * * @param options - Additional options for the rendering. * @public */ export function createTestAppWrapper( options: TestAppOptions = {}, ): (props: { children: ReactNode }) => JSX.Element { const { routeEntries = ['/'] } = options; const boundRoutes = new Map<ExternalRouteRef, RouteRef>(); const app = createSpecializedApp({ apis: mockApis, defaultApis, // Bit of a hack to make sure that the default config loader isn't used // as that would force every single test to wait for config loading. configLoader: false as unknown as undefined, components: { Progress, BootErrorPage, NotFoundErrorPage, ErrorBoundaryFallback, Router: ({ children }) => ( <MemoryRouter initialEntries={routeEntries} children={children} /> ), }, icons: mockIcons, plugins: [], themes: [ { id: 'light', title: 'Test App Theme', variant: 'light', Provider: ({ children }) => ( <ThemeProvider theme={lightTheme}> <CssBaseline>{children}</CssBaseline> </ThemeProvider> ), }, ], bindRoutes: ({ bind }) => { for (const [externalRef, absoluteRef] of boundRoutes) { bind( { ref: externalRef }, { ref: absoluteRef, }, ); } }, }); const routeElements = Object.entries(options.mountedRoutes ?? {}).map( ([path, routeRef]) => { const Page = () => <div>Mounted at {path}</div>; // Allow external route refs to be bound to paths as well, for convenience. // We work around it by creating and binding an absolute ref to the external one. if (isExternalRouteRef(routeRef)) { const absoluteRef = createRouteRef({ id: 'id' }); boundRoutes.set(routeRef, absoluteRef); attachComponentData(Page, 'core.mountPoint', absoluteRef); } else { attachComponentData(Page, 'core.mountPoint', routeRef); } return <Route key={path} path={path} element={<Page />} />; }, ); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); const TestAppWrapper = ({ children }: { children: ReactNode }) => ( <AppProvider> <AppRouter> <NoRender>{routeElements}</NoRender> {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element * and work with nested routes if they exist too */} <Routes> <Route path="/*" element={<>{children}</>} /> </Routes> </AppRouter> </AppProvider> ); return TestAppWrapper; } /** * Wraps a component inside a Backstage test app, providing a mocked theme * and app context, along with mocked APIs. * * @param Component - A component or react node to render inside the test app. * @param options - Additional options for the rendering. * @public */ export function wrapInTestApp( Component: ComponentType | ReactNode, options: TestAppOptions = {}, ): ReactElement { const TestAppWrapper = createTestAppWrapper(options); let wrappedElement: React.ReactElement; if (Component instanceof Function) { wrappedElement = <Component />; } else { wrappedElement = Component as React.ReactElement; } return <TestAppWrapper>{wrappedElement}</TestAppWrapper>; } /** * Renders a component inside a Backstage test app, providing a mocked theme * and app context, along with mocked APIs. * * The render executes async effects similar to `renderWithEffects`. To avoid this * behavior, use a regular `render()` + `wrapInTestApp()` instead. * * @param Component - A component or react node to render inside the test app. * @param options - Additional options for the rendering. * @public */ export async function renderInTestApp( Component: ComponentType | ReactNode, options: TestAppOptions = {}, ): Promise<RenderResult> { let wrappedElement: React.ReactElement; if (Component instanceof Function) { wrappedElement = <Component />; } else { wrappedElement = Component as React.ReactElement; } return renderWithEffects(wrappedElement, { wrapper: createTestAppWrapper(options), }); }