diff --git a/.changeset/fix-test-route-matching.md b/.changeset/fix-test-route-matching.md new file mode 100644 index 0000000000..c8adb6f8d5 --- /dev/null +++ b/.changeset/fix-test-route-matching.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added a `mountPath` option to `renderInTestApp` that controls the route path pattern the test element is rendered at. When set, the element is wrapped in a `` with the given path, enabling `useParams()` to extract route parameters from the URL. Use together with `initialRouteEntries` to set a concrete URL that matches the pattern. This is useful for testing page components that depend on URL parameters, such as entity pages that use `useRouteRefParams`. diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index d24046953f..309d304fe1 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -478,6 +478,7 @@ export type TestAppOptions = { }; config?: JsonObject; features?: FrontendFeature[]; + mountPath?: string; initialRouteEntries?: string[]; apis?: readonly [...TestApiPairs]; }; diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index d599bdca01..ada78a35a3 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -15,7 +15,7 @@ */ import { Fragment } from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { prepareSpecializedApp } from '@backstage/frontend-app-api'; import { RenderResult, render } from '@testing-library/react'; import { ConfigReader } from '@backstage/config'; @@ -79,6 +79,24 @@ export type TestAppOptions = { */ features?: FrontendFeature[]; + /** + * The route path pattern that the test element is rendered at. When set, + * the element is wrapped in a `` with this path, enabling + * `useParams()` to extract parameters from the URL. + * + * Should be used together with `initialRouteEntries` to set a concrete + * URL that matches the pattern. + * + * @example + * ```ts + * renderInTestApp(, { + * mountPath: '/catalog/:namespace/:kind/:name', + * initialRouteEntries: ['/catalog/default/component/my-entity'], + * }) + * ``` + */ + mountPath?: string; + /** * Initial route entries to use for the router. */ @@ -125,12 +143,28 @@ export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult { + const mountPath = options?.mountPath; + const extensions: Array = [ createExtension({ attachTo: { id: 'app/root', input: 'children' }, output: [coreExtensionData.reactElement], factory: () => { - return [coreExtensionData.reactElement(element)]; + let content: JSX.Element = element; + + if (mountPath) { + const routePath = + mountPath === '/' || mountPath.endsWith('/*') + ? mountPath + : `${mountPath.replace(/\/$/, '')}/*`; + content = ( + + + + ); + } + + return [coreExtensionData.reactElement(content)]; }, }), ]; diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 239f286515..6807817967 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -28,7 +28,6 @@ import { } from '@backstage/plugin-catalog-react/alpha'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { - catalogApiRef, entityRouteRef, MockStarredEntitiesApi, starredEntitiesApiRef, @@ -40,7 +39,7 @@ import { Entity } from '@backstage/catalog-model'; jest.setTimeout(30_000); describe('Entity page', () => { - const entityMock = { + const entityMock: Entity = { metadata: { namespace: 'default', annotations: { @@ -108,9 +107,9 @@ describe('Entity page', () => { ], }; - const mockCatalogApi = catalogApiMock.mock({ - getEntityByRef: async () => entityMock, - }); + const entityPath = '/catalog/default/component/artist-lookup'; + + const mockCatalogApi = catalogApiMock({ entities: [entityMock] }); const mockStarredEntitiesApi = new MockStarredEntitiesApi(); @@ -152,10 +151,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -175,11 +173,11 @@ describe('Entity page', () => { await expect( screen.findByRole('button', { name: /TechDocs/ }), - ).resolves.toHaveAttribute('href', '/techdocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/techdocs`); await expect( screen.findByRole('button', { name: /ApiDocs/ }), - ).resolves.toHaveAttribute('href', '/apidocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/apidocs`); }); it('Should rename a default group', async () => { @@ -199,10 +197,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -220,11 +217,11 @@ describe('Entity page', () => { await expect( screen.findByRole('button', { name: /TechDocs/ }), - ).resolves.toHaveAttribute('href', '/techdocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/techdocs`); await expect( screen.findByRole('button', { name: /ApiDocs/ }), - ).resolves.toHaveAttribute('href', '/apidocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/apidocs`); }); it('Should disassociate a content with a default group', async () => { @@ -239,10 +236,9 @@ describe('Entity page', () => { }); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -292,10 +288,9 @@ describe('Entity page', () => { }); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -313,11 +308,11 @@ describe('Entity page', () => { await expect( screen.findByRole('button', { name: /TechDocs/ }), - ).resolves.toHaveAttribute('href', '/techdocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/techdocs`); await expect( screen.findByRole('button', { name: /ApiDocs/ }), - ).resolves.toHaveAttribute('href', '/apidocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/apidocs`); }); it('Should render a single-content groups as a normal tab', async () => { @@ -333,10 +328,9 @@ describe('Entity page', () => { }); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -367,10 +361,9 @@ describe('Entity page', () => { .add(overviewEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -413,10 +406,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -434,11 +426,11 @@ describe('Entity page', () => { await expect( screen.findByRole('button', { name: /TechDocs/ }), - ).resolves.toHaveAttribute('href', '/techdocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/techdocs`); await expect( screen.findByRole('button', { name: /ApiDocs/ }), - ).resolves.toHaveAttribute('href', '/apidocs'); + ).resolves.toHaveAttribute('href', `${entityPath}/apidocs`); }); it('Should sort content by title by default', async () => { @@ -449,10 +441,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -490,10 +481,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -539,10 +529,9 @@ describe('Entity page', () => { .add(apidocsEntityContent); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -588,10 +577,9 @@ describe('Entity page', () => { }); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -625,10 +613,9 @@ describe('Entity page', () => { ); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -664,10 +651,9 @@ describe('Entity page', () => { ).add(customEntityHeader); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -723,10 +709,9 @@ describe('Entity page', () => { ).add(menuItem); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -778,10 +763,9 @@ describe('Entity page', () => { ).add(menuItem); await renderInTestApp(tester.reactElement(), { - apis: [ - [catalogApiRef, mockCatalogApi], - [starredEntitiesApiRef, mockStarredEntitiesApi], - ], + apis: [mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi]], + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -801,14 +785,14 @@ describe('Entity page', () => { screen.findByText(/artist-lookup/), ).resolves.toBeInTheDocument(); - await userEvent.click(screen.getByTestId('menu-button')); + await userEvent.click(await screen.findByTestId('menu-button')); await expect( screen.findByText('Test Title'), ).resolves.toBeInTheDocument(); await expect(screen.findByText('Test Icon')).resolves.toBeInTheDocument(); - const listItem = screen.getByText('Test Title').closest('li'); + const listItem = (await screen.findByText('Test Title')).closest('li'); expect(listItem).toHaveAttribute('aria-disabled', disabled.toString()); if (!disabled) { await userEvent.click(screen.getByText('Test Title')); @@ -870,6 +854,8 @@ describe('Entity page', () => { .add(filteredMenuItem); await renderInTestApp(tester.reactElement(), { + mountPath: '/catalog/:namespace/:kind/:name', + initialRouteEntries: [entityPath], config: { app: { title: 'Custom app', @@ -882,7 +868,7 @@ describe('Entity page', () => { convertLegacyRouteRef(entityRouteRef), }, apis: [ - [catalogApiRef, mockCatalogApi], + mockCatalogApi, [starredEntitiesApiRef, mockStarredEntitiesApi], ], });