diff --git a/.changeset/fluffy-cities-sip.md b/.changeset/fluffy-cities-sip.md new file mode 100644 index 0000000000..1f82540e38 --- /dev/null +++ b/.changeset/fluffy-cities-sip.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Fixed an issue that caused the `NotFound` page to not render correctly when a Page was mounted at `/`. diff --git a/.changeset/real-seals-dress.md b/.changeset/real-seals-dress.md new file mode 100644 index 0000000000..7f490d3ce9 --- /dev/null +++ b/.changeset/real-seals-dress.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': minor +--- + +**BREAKING**: Removed the `extensions` option from `renderInTestApp`. If you need to pass extensions to the test app, use the new `renderTestApp` utility instead. diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index fe4166016e..c5138dac3d 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -39,7 +39,7 @@ import { useApp, } from '@backstage/core-plugin-api'; import { screen } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderTestApp } from '@backstage/frontend-test-utils'; const exampleApiRef = createApiRef({ id: 'plugin.example.service', @@ -304,7 +304,7 @@ describe('collectLegacyRoutes', () => { , ); - renderInTestApp(
, { features }); + renderTestApp({ features }); await expect( screen.findByText('plugins: app, test'), diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx index 7bbe13867c..1cb2b757e3 100644 --- a/packages/core-compat-api/src/convertLegacyApp.test.tsx +++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx @@ -27,7 +27,7 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; import { EntityLayout, EntitySwitch, isKind } from '@backstage/plugin-catalog'; -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderTestApp } from '@backstage/frontend-test-utils'; import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; @@ -263,7 +263,7 @@ describe('convertLegacyApp', () => { }); // Overview - const renderOverviewTest = await renderInTestApp(
, { + const renderOverviewTest = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/test/x'], }); @@ -272,7 +272,7 @@ describe('convertLegacyApp', () => { ).resolves.toBeInTheDocument(); renderOverviewTest.unmount(); - const renderOverviewOther = await renderInTestApp(
, { + const renderOverviewOther = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/other/x'], }); @@ -282,7 +282,7 @@ describe('convertLegacyApp', () => { renderOverviewOther.unmount(); // Foo tab - const renderFooTest = await renderInTestApp(
, { + const renderFooTest = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/test/x/foo'], }); @@ -291,7 +291,7 @@ describe('convertLegacyApp', () => { ).resolves.toBeInTheDocument(); renderFooTest.unmount(); - const renderFooOther = await renderInTestApp(
, { + const renderFooOther = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/other/x/foo'], }); @@ -301,7 +301,7 @@ describe('convertLegacyApp', () => { renderFooOther.unmount(); // Bar tab - const renderBarTest = await renderInTestApp(
, { + const renderBarTest = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/test/x/bar'], }); @@ -310,7 +310,7 @@ describe('convertLegacyApp', () => { ).resolves.toBeInTheDocument(); renderBarTest.unmount(); - const renderBarOther = await renderInTestApp(
, { + const renderBarOther = await renderTestApp({ features: [catalogOverride, ...converted], initialRouteEntries: ['/catalog/default/other/x/bar'], }); diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx index b4ad8ed2d2..0f26af3243 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { Fragment } from 'react'; import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; import { screen, waitFor } from '@testing-library/react'; import { @@ -21,7 +21,7 @@ import { createExtension, createExtensionInput, } from '../wiring'; -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderTestApp } from '@backstage/frontend-test-utils'; describe('AppRootWrapperBlueprint', () => { it('should return an extension with sensible defaults', () => { @@ -63,7 +63,7 @@ describe('AppRootWrapperBlueprint', () => { }, }); - renderInTestApp(
, { extensions: [extension] }); + renderTestApp({ extensions: [extension] }); await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()); }); @@ -83,16 +83,18 @@ describe('AppRootWrapperBlueprint', () => { component: ({ children }) => (
{children} - {inputs.children.flatMap(c => - c.get(coreExtensionData.reactElement), - )} + {inputs.children.flatMap((c, index) => ( + + {c.get(coreExtensionData.reactElement)} + + ))}
), }); }, }); - renderInTestApp(
, { + renderTestApp({ extensions: [ extension, createExtension({ diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx index fae4bf7ebf..63436308ed 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderTestApp } from '@backstage/frontend-test-utils'; import { createSwappableComponent } from '../components'; import { SwappableComponentBlueprint } from './SwappableComponentBlueprint'; import { PageBlueprint } from './PageBlueprint'; -import { waitFor, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; describe('SwappableComponentBlueprint', () => { it('should allow defining a component override for a component ref', () => { @@ -48,21 +48,19 @@ describe('SwappableComponentBlueprint', () => { loader: () => (props: { hello: string }) =>
{props.hello}
, }); - renderInTestApp(
, { + renderTestApp({ extensions: [ PageBlueprint.make({ params: define => define({ - // todo(blam): there's a bug that this path cannot be `/`? - path: '/test', + path: '/', loader: async () => , }), }), ], - initialRouteEntries: ['/test'], }); - await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + await expect(screen.findByText('test!')).resolves.toBeInTheDocument(); }); it('should render a component ref without a default implementation', async () => { @@ -70,22 +68,21 @@ describe('SwappableComponentBlueprint', () => { id: 'test.component', }); - renderInTestApp(
, { + renderTestApp({ extensions: [ PageBlueprint.make({ params: define => define({ - path: '/test', + path: '/', loader: async () => , }), }), ], - initialRouteEntries: ['/test'], }); - await waitFor(() => - expect(screen.getByTestId('test.component')).toBeInTheDocument(), - ); + await expect( + screen.findByTestId('test.component'), + ).resolves.toBeInTheDocument(); }); it('should render a component ref with an async loader implementation', async () => { @@ -95,21 +92,20 @@ describe('SwappableComponentBlueprint', () => {
{props.hello}
, }); - renderInTestApp(
, { + renderTestApp({ extensions: [ PageBlueprint.make({ params: define => define({ // todo(blam): there's a bug that this path cannot be `/`? - path: '/test', + path: '/', loader: async () => , }), }), ], - initialRouteEntries: ['/test'], }); - await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + await expect(screen.findByText('test!')).resolves.toBeInTheDocument(); }); it('should render a component ref with an async loader implementation and prop transform', async () => { @@ -120,22 +116,18 @@ describe('SwappableComponentBlueprint', () => { transformProps: ({ hello }) => ({ hello: `tr ${hello}` }), }); - renderInTestApp(
, { + renderTestApp({ extensions: [ PageBlueprint.make({ params: define => define({ - // todo(blam): there's a bug that this path cannot be `/`? - path: '/test', + path: '/', loader: async () => , }), }), ], - initialRouteEntries: ['/test'], }); - await waitFor(() => - expect(screen.getByText('tr test!')).toBeInTheDocument(), - ); + await expect(screen.findByText('tr test!')).resolves.toBeInTheDocument(); }); }); diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index db31c082ea..b99cf843d4 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -29,6 +29,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { TestApiProvider } from '@backstage/test-utils'; import { TestApiProviderProps } from '@backstage/test-utils'; import { TestApiRegistry } from '@backstage/test-utils'; +import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; import { withLogCollector } from '@backstage/test-utils'; export { ApiMock }; @@ -119,6 +120,19 @@ export function renderInTestApp( options?: TestAppOptions, ): RenderResult; +// @public +export function renderTestApp( + options: RenderTestAppOptions, +): RenderResult; + +// @public +export type RenderTestAppOptions = { + config?: JsonObject; + extensions?: ExtensionDefinition[]; + features?: FrontendFeature[]; + initialRouteEntries?: string[]; +}; + export { TestApiProvider }; export { TestApiProviderProps }; @@ -131,7 +145,6 @@ export type TestAppOptions = { [path: string]: RouteRef; }; config?: JsonObject; - extensions?: ExtensionDefinition[]; features?: FrontendFeature[]; initialRouteEntries?: string[]; }; diff --git a/packages/frontend-test-utils/src/app/index.ts b/packages/frontend-test-utils/src/app/index.ts index 6c6ef26e4c..9db67dbb49 100644 --- a/packages/frontend-test-utils/src/app/index.ts +++ b/packages/frontend-test-utils/src/app/index.ts @@ -21,3 +21,4 @@ export { } from './createExtensionTester'; export { renderInTestApp, type TestAppOptions } from './renderInTestApp'; +export { renderTestApp, type RenderTestAppOptions } from './renderTestApp'; diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index 80818509da..747229e8d6 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -67,11 +67,6 @@ export type TestAppOptions = { */ config?: JsonObject; - /** - * Additional extensions to add to the test app. - */ - extensions?: ExtensionDefinition[]; - /** * Additional features to add to the test app. */ @@ -107,6 +102,12 @@ const appPluginOverride = appPlugin.withOverrides({ appPlugin.getExtension('sign-in-page:app').override({ disabled: true, }), + appPlugin.getExtension('app/layout').override({ + disabled: true, + }), + appPlugin.getExtension('app/routes').override({ + disabled: true, + }), appPlugin.getExtension('app/nav').override({ output: [coreExtensionData.reactElement], factory(_originalFactory, { inputs }) { @@ -147,13 +148,10 @@ export function renderInTestApp( ): RenderResult { const extensions: Array = [ createExtension({ - attachTo: { id: 'app/routes', input: 'routes' }, - output: [coreExtensionData.reactElement, coreExtensionData.routePath], + attachTo: { id: 'app/root', input: 'children' }, + output: [coreExtensionData.reactElement], factory: () => { - return [ - coreExtensionData.reactElement(element), - coreExtensionData.routePath('/'), - ]; + return [coreExtensionData.reactElement(element)]; }, }), RouterBlueprint.make({ @@ -190,10 +188,6 @@ export function renderInTestApp( } } - if (options?.extensions) { - extensions.push(...options.extensions); - } - const features: FrontendFeature[] = [ createFrontendPlugin({ pluginId: 'test', diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx new file mode 100644 index 0000000000..e8d90cadd6 --- /dev/null +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2025 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 { + coreExtensionData, + createFrontendPlugin, + ExtensionDefinition, + FrontendFeature, + RouterBlueprint, +} from '@backstage/frontend-plugin-api'; +import { render } from '@testing-library/react'; +import appPlugin from '@backstage/plugin-app'; +import { JsonObject } from '@backstage/types'; +import { ConfigReader } from '@backstage/config'; +import { MemoryRouter } from 'react-router-dom'; + +const DEFAULT_MOCK_CONFIG = { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, +}; + +/** + * Options for `renderTestApp`. + * + * @public + */ +export type RenderTestAppOptions = { + /** + * Additional configuration passed to the app when rendering elements inside it. + */ + config?: JsonObject; + /** + * Additional extensions to add to the test app. + */ + extensions?: ExtensionDefinition[]; + + /** + * Additional features to add to the test app. + */ + features?: FrontendFeature[]; + + /** + * Initial route entries to use for the router. + */ + initialRouteEntries?: string[]; +}; + +const appPluginOverride = appPlugin.withOverrides({ + extensions: [ + appPlugin.getExtension('sign-in-page:app').override({ + disabled: true, + }), + ], +}); + +/** + * Renders the provided extensions inside a Backstage app, returning the same + * utilities as `@testing-library/react` `render` function. + * + * @public + */ +export function renderTestApp(options: RenderTestAppOptions) { + const extensions = [ + RouterBlueprint.make({ + params: { + component: ({ children }) => ( + + {children} + + ), + }, + }), + ...(options.extensions ?? []), + ]; + + const features: FrontendFeature[] = [ + createFrontendPlugin({ + pluginId: 'test', + extensions, + }), + appPluginOverride, + ]; + + if (options.features) { + features.push(...options.features); + } + + const app = createSpecializedApp({ + features, + config: ConfigReader.fromConfigs([ + { + context: 'render-config', + data: options?.config ?? DEFAULT_MOCK_CONFIG, + }, + ]), + }); + + return render( + app.tree.root.instance!.getData(coreExtensionData.reactElement), + ); +} diff --git a/plugins/app/src/apis/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx b/plugins/app/src/apis/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx index 07ef891926..bdd81f338c 100644 --- a/plugins/app/src/apis/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/plugins/app/src/apis/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -16,6 +16,7 @@ import { ApiBlueprint, + AppRootElementBlueprint, createExtensionInput, createFrontendModule, createSwappableComponent, @@ -24,7 +25,7 @@ import { } from '@backstage/frontend-plugin-api'; import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi'; import { render, screen } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderInTestApp, renderTestApp } from '@backstage/frontend-test-utils'; const { ref: testRefA } = createSwappableComponent({ id: 'test.a' }); const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' }); @@ -149,8 +150,17 @@ describe('DefaultSwappableComponentsApi', () => { id: 'test.mock', }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], }); await expect( @@ -164,8 +174,17 @@ describe('DefaultSwappableComponentsApi', () => { loader: () => () =>
test.mock
, }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], }); await expect( @@ -179,8 +198,17 @@ describe('DefaultSwappableComponentsApi', () => { loader: async () => () =>
test.mock
, }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], }); await expect( @@ -202,8 +230,17 @@ describe('DefaultSwappableComponentsApi', () => { }), }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], features: [ createFrontendModule({ pluginId: 'app', @@ -231,8 +268,17 @@ describe('DefaultSwappableComponentsApi', () => { }), }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], features: [ createFrontendModule({ pluginId: 'app', @@ -264,8 +310,17 @@ describe('DefaultSwappableComponentsApi', () => { }), }); - renderInTestApp(, { - extensions: [api], + renderTestApp({ + extensions: [ + AppRootElementBlueprint.make({ + name: 'derp', + params: define => + define({ + element: , + }), + }), + api, + ], features: [ createFrontendModule({ pluginId: 'app', diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 5cc3c3f4b8..ddd7962004 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -36,12 +36,18 @@ export const AppRoutes = createExtension({ factory({ inputs }) { const Routes = () => { const element = useRoutes([ - ...inputs.routes.map(route => ({ - path: `${route - .get(coreExtensionData.routePath) - .replace(/\/$/, '')}/*`, - element: route.get(coreExtensionData.reactElement), - })), + ...inputs.routes.map(route => { + const routePath = route.get(coreExtensionData.routePath); + + return { + path: + routePath === '/' + ? routePath + : `${routePath.replace(/\/$/, '')}/*`, + + element: route.get(coreExtensionData.reactElement), + }; + }), { path: '*', element: , diff --git a/plugins/app/src/extensions/DialogDisplay.test.tsx b/plugins/app/src/extensions/DialogDisplay.test.tsx index 8f7eb4e857..dbad551fcb 100644 --- a/plugins/app/src/extensions/DialogDisplay.test.tsx +++ b/plugins/app/src/extensions/DialogDisplay.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { renderTestApp } from '@backstage/frontend-test-utils'; import { act, useEffect } from 'react'; import { AppRootElementBlueprint, @@ -29,7 +29,7 @@ async function withDialogApi( callback: (dialogApi: DialogApi) => Promise, ) { const deferred = createDeferred(); - await renderInTestApp(
, { + await renderTestApp({ extensions: [ AppRootElementBlueprint.makeWithOverrides({ name: 'derp',