Merge pull request #31353 from backstage/fix/not-found

NFS: fixed Not found page when a page extension is mounted on /
This commit is contained in:
Vincenzo Scamporlino
2025-10-13 16:14:35 +02:00
committed by GitHub
13 changed files with 265 additions and 77 deletions
+5
View File
@@ -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 `/`.
+5
View File
@@ -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.
@@ -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<string>({
id: 'plugin.example.service',
@@ -304,7 +304,7 @@ describe('collectLegacyRoutes', () => {
</FlatRoutes>,
);
renderInTestApp(<div />, { features });
renderTestApp({ features });
await expect(
screen.findByText('plugins: app, test'),
@@ -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(<div />, {
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(<div />, {
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(<div />, {
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(<div />, {
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(<div />, {
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(<div />, {
const renderBarOther = await renderTestApp({
features: [catalogOverride, ...converted],
initialRouteEntries: ['/catalog/default/other/x/bar'],
});
@@ -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(<div />, { extensions: [extension] });
renderTestApp({ extensions: [extension] });
await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument());
});
@@ -83,16 +83,18 @@ describe('AppRootWrapperBlueprint', () => {
component: ({ children }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{children}
{inputs.children.flatMap(c =>
c.get(coreExtensionData.reactElement),
)}
{inputs.children.flatMap((c, index) => (
<Fragment key={index}>
{c.get(coreExtensionData.reactElement)}
</Fragment>
))}
</div>
),
});
},
});
renderInTestApp(<div />, {
renderTestApp({
extensions: [
extension,
createExtension({
@@ -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 }) => <div>{props.hello}</div>,
});
renderInTestApp(<div />, {
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
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(<div />, {
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
path: '/test',
path: '/',
loader: async () => <TestComponent />,
}),
}),
],
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', () => {
<div>{props.hello}</div>,
});
renderInTestApp(<div />, {
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
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(<div />, {
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
initialRouteEntries: ['/test'],
});
await waitFor(() =>
expect(screen.getByText('tr test!')).toBeInTheDocument(),
);
await expect(screen.findByText('tr test!')).resolves.toBeInTheDocument();
});
});
+14 -1
View File
@@ -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<testingLibraryDomTypesQueries, HTMLElement, HTMLElement>;
// @public
export type RenderTestAppOptions = {
config?: JsonObject;
extensions?: ExtensionDefinition<any>[];
features?: FrontendFeature[];
initialRouteEntries?: string[];
};
export { TestApiProvider };
export { TestApiProviderProps };
@@ -131,7 +145,6 @@ export type TestAppOptions = {
[path: string]: RouteRef;
};
config?: JsonObject;
extensions?: ExtensionDefinition<any>[];
features?: FrontendFeature[];
initialRouteEntries?: string[];
};
@@ -21,3 +21,4 @@ export {
} from './createExtensionTester';
export { renderInTestApp, type TestAppOptions } from './renderInTestApp';
export { renderTestApp, type RenderTestAppOptions } from './renderTestApp';
@@ -67,11 +67,6 @@ export type TestAppOptions = {
*/
config?: JsonObject;
/**
* Additional extensions to add to the test app.
*/
extensions?: ExtensionDefinition<any>[];
/**
* 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<ExtensionDefinition> = [
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',
@@ -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<any>[];
/**
* 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 }) => (
<MemoryRouter initialEntries={options.initialRouteEntries}>
{children}
</MemoryRouter>
),
},
}),
...(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),
);
}
@@ -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(<MockComponent />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent />,
}),
}),
api,
],
});
await expect(
@@ -164,8 +174,17 @@ describe('DefaultSwappableComponentsApi', () => {
loader: () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent />,
}),
}),
api,
],
});
await expect(
@@ -179,8 +198,17 @@ describe('DefaultSwappableComponentsApi', () => {
loader: async () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent />,
}),
}),
api,
],
});
await expect(
@@ -202,8 +230,17 @@ describe('DefaultSwappableComponentsApi', () => {
}),
});
renderInTestApp(<MockComponent />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent />,
}),
}),
api,
],
features: [
createFrontendModule({
pluginId: 'app',
@@ -231,8 +268,17 @@ describe('DefaultSwappableComponentsApi', () => {
}),
});
renderInTestApp(<MockComponent />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent />,
}),
}),
api,
],
features: [
createFrontendModule({
pluginId: 'app',
@@ -264,8 +310,17 @@ describe('DefaultSwappableComponentsApi', () => {
}),
});
renderInTestApp(<MockComponent external="test" />, {
extensions: [api],
renderTestApp({
extensions: [
AppRootElementBlueprint.make({
name: 'derp',
params: define =>
define({
element: <MockComponent external="test" />,
}),
}),
api,
],
features: [
createFrontendModule({
pluginId: 'app',
+12 -6
View File
@@ -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: <NotFoundErrorPage />,
@@ -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<T>(
callback: (dialogApi: DialogApi) => Promise<T>,
) {
const deferred = createDeferred<DialogApi>();
await renderInTestApp(<div />, {
await renderTestApp({
extensions: [
AppRootElementBlueprint.makeWithOverrides({
name: 'derp',