From 28adb4e03b825355a023f58cecf39a4653a5e58c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Jul 2022 13:34:00 +0200 Subject: [PATCH] core-app-api: wip tests for RoutingProvider Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../core-app-api/src/routing/RouteResolver.ts | 10 +- ...test.tsx => RoutingProvider.beta.test.tsx} | 0 .../routing/RoutingProvider.stable.test.tsx | 412 ++++++++++++++++++ packages/core-app-api/src/routing/helpers.ts | 24 + .../core-app-api/src/routing/validation.ts | 3 +- 5 files changed, 439 insertions(+), 10 deletions(-) rename packages/core-app-api/src/routing/{RoutingProvider.test.tsx => RoutingProvider.beta.test.tsx} (100%) create mode 100644 packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx create mode 100644 packages/core-app-api/src/routing/helpers.ts diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts index 7cd9569c0b..31ea23d59f 100644 --- a/packages/core-app-api/src/routing/RouteResolver.ts +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -30,15 +30,7 @@ import { ExternalRouteRef, SubRouteRef, } from '@backstage/core-plugin-api'; - -// Joins a list of paths together, avoiding trailing and duplicate slashes -function joinPaths(...paths: string[]): string { - const normalized = paths.join('/').replace(/\/\/+/g, '/'); - if (normalized !== '/' && normalized.endsWith('/')) { - return normalized.slice(0, -1); - } - return normalized; -} +import { joinPaths } from './helpers'; /** * Resolves the absolute route ref that our target route ref is pointing pointing to, as well diff --git a/packages/core-app-api/src/routing/RoutingProvider.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx similarity index 100% rename from packages/core-app-api/src/routing/RoutingProvider.test.tsx rename to packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx diff --git a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx new file mode 100644 index 0000000000..c5ea3ed9e0 --- /dev/null +++ b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx @@ -0,0 +1,412 @@ +/* + * 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, { PropsWithChildren, ReactElement } from 'react'; +import { MemoryRouter, Routes, Route, useOutlet } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useVersionedContext } from '@backstage/version-bridge'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { + createPlugin, + useRouteRef, + createRoutableExtension, + createRouteRef, + createExternalRouteRef, + RouteRef, + ExternalRouteRef, +} from '@backstage/core-plugin-api'; +import { RoutingProvider } from './RoutingProvider'; +import { routingV2Collector } from './collectors'; +import { validateRouteParameters } from './validation'; +import { RouteResolver } from './RouteResolver'; +import { AnyRouteRef, RouteFunc } from './types'; +import { AppContextProvider } from '../app/AppContext'; + +jest.mock('react-router', () => jest.requireActual('react-router-stable')); +jest.mock('react-router-dom', () => + jest.requireActual('react-router-dom-stable'), +); + +const MockComponent = ({ children }: PropsWithChildren<{}>) => ( + <> + {children} +
{useOutlet()}
+ +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const refPage1 = createRouteRef({ id: 'refPage1' }); +const refSource1 = createRouteRef({ id: 'refSource1' }); +const refPage2 = createRouteRef({ id: 'refPage2' }); +const refSource2 = createRouteRef({ id: 'refSource2' }); +const refPage3 = createRouteRef({ id: 'refPage3', params: ['x'] }); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ + id: '5', + optional: true, + params: ['z'], +}); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: AnyRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; + return ( +
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}, {String(ex)} +
+ ); + } +}; + +const ExtensionPage1 = plugin.provide( + createRoutableExtension({ + name: 'ExtensionPage1', + component: () => Promise.resolve(MockComponent), + mountPoint: refPage1, + }), +); +const ExtensionPage2 = plugin.provide( + createRoutableExtension({ + name: 'ExtensionPage2', + component: () => Promise.resolve(MockComponent), + mountPoint: refPage2, + }), +); +const ExtensionPage3 = plugin.provide( + createRoutableExtension({ + name: 'ExtensionPage3', + component: () => Promise.resolve(MockComponent), + mountPoint: refPage3, + }), +); +const ExtensionSource1 = plugin.provide( + createRoutableExtension({ + name: 'ExtensionSource1', + component: () => Promise.resolve(MockRouteSource), + mountPoint: refSource1, + }), +); +const ExtensionSource2 = plugin.provide( + createRoutableExtension({ + name: 'ExtensionSource2', + component: () => Promise.resolve(MockRouteSource), + mountPoint: refSource2, + }), +); + +const mockContext = { + getComponents: () => ({ Progress: () => null } as any), + getSystemIcon: jest.fn(), + getSystemIcons: jest.fn(), + getPlugins: jest.fn(), +}; + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routing } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routing: routingV2Collector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + } + > + + } + /> + + } /> + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, refPage2], + [eRefB, refPage1], + [eRefC, refSource1], + [eRefD, refPage1], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + }> + + } + /> + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + }> + + } + /> + } /> + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + rendered.debug(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + }> + } + /> + } /> + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams, Error: Cannot route to ${refPage2} with parent ${refPage3} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams, Error: Cannot route to ${refPage2} with parent ${refPage3} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + }> + } + /> + + + + ); + + const { routing } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routing: routingV2Collector, + }, + }); + + expect(() => + validateRouteParameters(routing.paths, routing.parents), + ).toThrow('Parameter :id is duplicated in path foo/:id/bar/:id'); + }); +}); + +describe('v1 consumer', () => { + function useMockRouteRefV1( + routeRef: AnyRouteRef, + location: string, + ): RouteFunc | undefined { + const resolver = useVersionedContext<{ + 1: RouteResolver; + }>('routing-context')?.atVersion(1); + if (!resolver) { + throw new Error('no impl'); + } + return resolver.resolve(routeRef, location); + } + + it('should resolve routes', () => { + const routeRef1 = createRouteRef({ id: 'refPage1' }); + const routeRef2 = createRouteRef({ id: 'refSource1' }); + const routeRef3 = createRouteRef({ id: 'refPage2', params: ['x'] }); + + const renderedHook = renderHook( + ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), + { + initialProps: { + routeRef: routeRef1 as AnyRouteRef, + }, + wrapper: ({ children }) => ( + , string>([ + [routeRef2, '/foo'], + [routeRef3, '/bar/:x'], + ]) + } + routeParents={new Map()} + routeObjects={[]} + routeBindings={new Map()} + basePath="/base" + children={children} + /> + ), + }, + ); + + expect(renderedHook.result.current).toBe(undefined); + renderedHook.rerender({ routeRef: routeRef2 }); + expect(renderedHook.result.current?.()).toBe('/base/foo'); + renderedHook.rerender({ routeRef: routeRef3 }); + expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/base/bar/my-x'); + }); +}); diff --git a/packages/core-app-api/src/routing/helpers.ts b/packages/core-app-api/src/routing/helpers.ts new file mode 100644 index 0000000000..b25f602d52 --- /dev/null +++ b/packages/core-app-api/src/routing/helpers.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 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. + */ + +// Joins a list of paths together, avoiding trailing and duplicate slashes +export function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} diff --git a/packages/core-app-api/src/routing/validation.ts b/packages/core-app-api/src/routing/validation.ts index c5d3e4aca9..d8be7ee9bc 100644 --- a/packages/core-app-api/src/routing/validation.ts +++ b/packages/core-app-api/src/routing/validation.ts @@ -20,6 +20,7 @@ import { RouteRef, SubRouteRef, } from '@backstage/core-plugin-api'; +import { joinPaths } from './helpers'; import { AnyRouteRef } from './types'; // Validates that there is no duplication of route parameter names @@ -43,7 +44,7 @@ export function validateRouteParameters( if (!path) { throw new Error(`No path for ${currentRouteRef}`); } - fullPath = `${path}${fullPath}`; + fullPath = joinPaths(path, fullPath); currentRouteRef = routeParents.get(currentRouteRef); }