diff --git a/packages/app/src/api/LearningPathApiClient.test.ts b/packages/app/src/api/LearningPathApiClient.test.ts
new file mode 100644
index 0000000000..526d5eaafe
--- /dev/null
+++ b/packages/app/src/api/LearningPathApiClient.test.ts
@@ -0,0 +1,84 @@
+import {
+ ConfigApi,
+ DiscoveryApi,
+ IdentityApi,
+} from '@backstage/core-plugin-api';
+
+import { LearningPathApiClient } from './LearningPathApiClient';
+
+const learningPaths = [
+ {
+ label: 'Operators on OpenShift',
+ url: 'https://example.com',
+ minutes: 20,
+ paths: 6,
+ },
+];
+
+const buildClient = (opts?: { proxyPath?: string; token?: string }) => {
+ const discoveryApi = {
+ getBaseUrl: jest.fn().mockResolvedValue('http://localhost/api/proxy'),
+ } as unknown as DiscoveryApi;
+ const configApi = {
+ getOptionalString: jest.fn().mockReturnValue(opts?.proxyPath),
+ } as unknown as ConfigApi;
+ const identityApi = {
+ getCredentials: jest.fn().mockResolvedValue({ token: opts?.token }),
+ } as unknown as IdentityApi;
+ return new LearningPathApiClient({ discoveryApi, configApi, identityApi });
+};
+
+describe('LearningPathApiClient', () => {
+ let fetchSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ fetchSpy = jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValue(
+ new Response(JSON.stringify(learningPaths), { status: 200 }),
+ );
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('fetches learning paths from the default proxy path with a bearer token', async () => {
+ const data = await buildClient({ token: 'tok-1' }).getLearningPathData();
+
+ expect(data).toEqual(learningPaths);
+ expect(fetchSpy).toHaveBeenCalledWith(
+ 'http://localhost/api/proxy/developer-hub/learning-paths',
+ expect.objectContaining({
+ headers: expect.objectContaining({ Authorization: 'Bearer tok-1' }),
+ }),
+ );
+ });
+
+ it('uses the configured developerHub.proxyPath when set', async () => {
+ await buildClient({
+ proxyPath: '/custom',
+ token: 'tok',
+ }).getLearningPathData();
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ 'http://localhost/api/proxy/custom/learning-paths',
+ expect.anything(),
+ );
+ });
+
+ it('omits the Authorization header when there is no token', async () => {
+ await buildClient({}).getLearningPathData();
+
+ const [, init] = fetchSpy.mock.calls[0];
+ expect(init.headers).not.toHaveProperty('Authorization');
+ });
+
+ it('throws a descriptive error when the response is not ok', async () => {
+ fetchSpy.mockResolvedValue(
+ new Response('nope', { status: 500, statusText: 'Server Error' }),
+ );
+
+ await expect(
+ buildClient({ token: 'tok' }).getLearningPathData(),
+ ).rejects.toThrow(/status 500: Server Error/);
+ });
+});
diff --git a/packages/app/src/components/ErrorPages/ErrorPage.test.tsx b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx
new file mode 100644
index 0000000000..ce104adaf3
--- /dev/null
+++ b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx
@@ -0,0 +1,84 @@
+import { configApiRef } from '@backstage/core-plugin-api';
+import {
+ mockApis,
+ renderInTestApp,
+ TestApiProvider,
+} from '@backstage/test-utils';
+
+import { screen } from '@testing-library/react';
+
+import { ErrorPage, ErrorPageProps } from './ErrorPage';
+
+jest.mock('../../hooks/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const renderErrorPage = async (props: ErrorPageProps) =>
+ renderInTestApp(
+
+
+ ,
+ );
+
+describe('ErrorPage', () => {
+ const originalHistoryLength = window.history.length;
+
+ afterEach(() => {
+ Object.defineProperty(window.history, 'length', {
+ value: originalHistoryLength,
+ configurable: true,
+ });
+ });
+
+ it('renders the status, message and additional info', async () => {
+ await renderErrorPage({
+ status: '500',
+ statusMessage: 'Internal Server Error',
+ additionalInfo: 'Something went wrong',
+ });
+
+ expect(screen.getByText('500')).toBeInTheDocument();
+ expect(screen.getByText('Internal Server Error')).toBeInTheDocument();
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('always offers the contact-support action', async () => {
+ await renderErrorPage({ status: '500', statusMessage: 'Error' });
+
+ expect(
+ screen.getByRole('link', { name: 'app.errors.contactSupport' }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the stack trace when one is provided', async () => {
+ await renderErrorPage({
+ status: '500',
+ statusMessage: 'Error',
+ stack: 'Error: boom\n at foo',
+ });
+
+ expect(screen.getByText(/Error: boom/)).toBeInTheDocument();
+ });
+
+ it('omits the go-back action for non-404 errors', async () => {
+ await renderErrorPage({ status: '500', statusMessage: 'Error' });
+
+ expect(
+ screen.queryByRole('button', { name: 'app.errors.goBack' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('offers the go-back action for 404 errors when there is history', async () => {
+ // GoBackButton only renders when there is meaningful history to go back to.
+ Object.defineProperty(window.history, 'length', {
+ value: 3,
+ configurable: true,
+ });
+
+ await renderErrorPage({ status: '404', statusMessage: 'Not Found' });
+
+ expect(
+ screen.getByRole('button', { name: 'app.errors.goBack' }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/app/src/components/ErrorPages/errorButtons/ContactSupportButton.test.tsx b/packages/app/src/components/ErrorPages/errorButtons/ContactSupportButton.test.tsx
new file mode 100644
index 0000000000..eafb4afdcd
--- /dev/null
+++ b/packages/app/src/components/ErrorPages/errorButtons/ContactSupportButton.test.tsx
@@ -0,0 +1,58 @@
+import { configApiRef } from '@backstage/core-plugin-api';
+import {
+ mockApis,
+ renderInTestApp,
+ TestApiProvider,
+} from '@backstage/test-utils';
+
+import { screen } from '@testing-library/react';
+
+import { ContactSupportButton } from './ContactSupportButton';
+
+jest.mock('../../../hooks/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const renderButton = async (
+ props: { supportUrl?: string } = {},
+ configData: object = {},
+) =>
+ renderInTestApp(
+
+
+ ,
+ );
+
+const supportLink = () =>
+ screen.getByRole('link', { name: 'app.errors.contactSupport' });
+
+describe('ContactSupportButton', () => {
+ it('prefers the explicit supportUrl prop', async () => {
+ await renderButton(
+ { supportUrl: 'https://prop.example.com' },
+ { app: { support: { url: 'https://config.example.com' } } },
+ );
+
+ expect(supportLink()).toHaveAttribute('href', 'https://prop.example.com');
+ });
+
+ it('falls back to the configured support URL', async () => {
+ await renderButton(
+ {},
+ { app: { support: { url: 'https://config.example.com' } } },
+ );
+
+ expect(supportLink()).toHaveAttribute('href', 'https://config.example.com');
+ });
+
+ it('falls back to the default Red Hat support URL', async () => {
+ await renderButton({}, {});
+
+ expect(supportLink()).toHaveAttribute(
+ 'href',
+ 'https://access.redhat.com/documentation/red_hat_developer_hub',
+ );
+ });
+});
diff --git a/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx b/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx
new file mode 100644
index 0000000000..778f51150b
--- /dev/null
+++ b/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+
+import { GoBackButton } from './GoBackButton';
+
+const mockNavigate = jest.fn();
+
+// useNavigate is mocked, so no router context (MemoryRouter) is needed.
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+}));
+
+jest.mock('../../../hooks/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const setHistoryLength = (length: number) =>
+ Object.defineProperty(window.history, 'length', {
+ value: length,
+ configurable: true,
+ });
+
+const renderButton = () => render();
+
+describe('GoBackButton', () => {
+ const originalHistoryLength = window.history.length;
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ setHistoryLength(originalHistoryLength);
+ });
+
+ it('navigates back when there is history to go back to', async () => {
+ setHistoryLength(3);
+
+ renderButton();
+ await userEvent.click(
+ screen.getByRole('button', { name: 'app.errors.goBack' }),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
+ });
+
+ it('renders nothing when there is no meaningful history', () => {
+ setHistoryLength(1);
+
+ renderButton();
+
+ expect(
+ screen.queryByRole('button', { name: 'app.errors.goBack' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/app/src/components/Root/MenuIcon.test.tsx b/packages/app/src/components/Root/MenuIcon.test.tsx
new file mode 100644
index 0000000000..a3b6654f89
--- /dev/null
+++ b/packages/app/src/components/Root/MenuIcon.test.tsx
@@ -0,0 +1,66 @@
+import { useApp } from '@backstage/core-plugin-api';
+
+import { render, screen } from '@testing-library/react';
+
+import { MenuIcon } from './MenuIcon';
+
+jest.mock('@backstage/core-plugin-api', () => ({
+ ...jest.requireActual('@backstage/core-plugin-api'),
+ useApp: jest.fn(),
+}));
+
+const mockGetSystemIcon = jest.fn();
+
+beforeEach(() => {
+ mockGetSystemIcon.mockReset();
+ (useApp as jest.Mock).mockReturnValue({ getSystemIcon: mockGetSystemIcon });
+});
+
+describe('MenuIcon', () => {
+ it('renders nothing when the icon is empty', () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders the registered system icon when one matches', () => {
+ const SystemIcon = () => ;
+ mockGetSystemIcon.mockReturnValue(SystemIcon);
+
+ render();
+
+ expect(screen.getByTestId('system-icon')).toBeInTheDocument();
+ });
+
+ it('renders an inline SVG icon as a base64 image', () => {
+ mockGetSystemIcon.mockReturnValue(undefined);
+
+ const { container } = render();
+
+ expect(container.querySelector('img')).toHaveAttribute(
+ 'src',
+ expect.stringContaining('data:image/svg+xml;base64,'),
+ );
+ });
+
+ it('renders a URL icon using the URL as the image source', () => {
+ mockGetSystemIcon.mockReturnValue(undefined);
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('img')).toHaveAttribute(
+ 'src',
+ 'https://example.com/icon.png',
+ );
+ });
+
+ it('renders a material icon name as text', () => {
+ mockGetSystemIcon.mockReturnValue(undefined);
+
+ render();
+
+ expect(screen.getByText('home')).toBeInTheDocument();
+ });
+});
diff --git a/packages/app/src/components/Root/ResizableDrawer.test.tsx b/packages/app/src/components/Root/ResizableDrawer.test.tsx
new file mode 100644
index 0000000000..7e14aa5c73
--- /dev/null
+++ b/packages/app/src/components/Root/ResizableDrawer.test.tsx
@@ -0,0 +1,132 @@
+import { renderInTestApp } from '@backstage/test-utils';
+
+import { render, screen } from '@testing-library/react';
+
+import { ResizableDrawer } from './ResizableDrawer';
+
+// The resize handle is an unlabelled styled div; locate it by structure and
+// assert it was found so a structural change fails loudly instead of silently
+// skipping the drag.
+const startDragFromHandle = () => {
+ const handle = document.querySelector(
+ '[class*="MuiBox-root"] > div:last-child',
+ );
+ expect(handle).not.toBeNull();
+ handle!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+};
+
+const findWindowHandler = (addSpy: jest.SpyInstance, event: string) =>
+ addSpy.mock.calls.find(([name]) => name === event)?.[1] as (
+ e: MouseEvent,
+ ) => void;
+
+describe('ResizableDrawer', () => {
+ it('renders its children while open', async () => {
+ await renderInTestApp(
+
+ drawer content
+ ,
+ );
+
+ expect(screen.getByText('drawer content')).toBeInTheDocument();
+ });
+
+ it('registers window resize listeners only when resizable', async () => {
+ const addSpy = jest.spyOn(globalThis, 'addEventListener');
+
+ await renderInTestApp(
+
+ drawer content
+ ,
+ );
+
+ expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function));
+ expect(addSpy).toHaveBeenCalledWith('mouseup', expect.any(Function));
+
+ addSpy.mockRestore();
+ });
+
+ it('drives onWidthChange from a resize drag', async () => {
+ const onWidthChange = jest.fn();
+ const addSpy = jest.spyOn(globalThis, 'addEventListener');
+
+ await renderInTestApp(
+
+ drawer content
+ ,
+ );
+
+ // Width for a right-anchored drawer is innerWidth - clientX.
+ startDragFromHandle();
+ globalThis.innerWidth = 1000;
+ findWindowHandler(
+ addSpy,
+ 'mousemove',
+ )(new MouseEvent('mousemove', { clientX: 400 }));
+
+ expect(onWidthChange).toHaveBeenCalledWith(600);
+
+ addSpy.mockRestore();
+ });
+
+ it('stops resizing after mouse up', async () => {
+ const onWidthChange = jest.fn();
+ const addSpy = jest.spyOn(globalThis, 'addEventListener');
+
+ await renderInTestApp(
+
+ drawer content
+ ,
+ );
+
+ startDragFromHandle();
+
+ // Ending the drag should make subsequent moves no-ops.
+ findWindowHandler(addSpy, 'mouseup')(new MouseEvent('mouseup'));
+ globalThis.innerWidth = 1000;
+ findWindowHandler(
+ addSpy,
+ 'mousemove',
+ )(new MouseEvent('mousemove', { clientX: 400 }));
+
+ expect(onWidthChange).not.toHaveBeenCalled();
+
+ addSpy.mockRestore();
+ });
+
+ it('re-clamps and reports when the external width drops below the minimum', () => {
+ const onWidthChange = jest.fn();
+ const drawer = (drawerWidth: number) => (
+
+ drawer content
+
+ );
+
+ // Initial width (500) is already within range, so nothing is reported.
+ const { rerender } = render(drawer(500));
+ expect(onWidthChange).not.toHaveBeenCalled();
+
+ // Dropping the external width below the minimum re-clamps to 400 and
+ // reports the corrected width back to the parent.
+ rerender(drawer(100));
+ expect(onWidthChange).toHaveBeenCalledWith(400);
+ });
+});
diff --git a/packages/app/src/components/SignInPage/SignInPage.test.tsx b/packages/app/src/components/SignInPage/SignInPage.test.tsx
new file mode 100644
index 0000000000..065279d0af
--- /dev/null
+++ b/packages/app/src/components/SignInPage/SignInPage.test.tsx
@@ -0,0 +1,87 @@
+import { configApiRef } from '@backstage/core-plugin-api';
+import {
+ mockApis,
+ renderInTestApp,
+ TestApiProvider,
+} from '@backstage/test-utils';
+
+import { screen } from '@testing-library/react';
+
+import { SignInPage } from './SignInPage';
+
+jest.mock('../../hooks/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+// Stub the heavy core-components sign-in widgets so the test isolates this
+// component's own provider-selection logic (which providers / proxied vs local).
+jest.mock('@backstage/core-components', () => ({
+ ...jest.requireActual('@backstage/core-components'),
+ SignInPage: (props: { providers: (string | { id: string })[] }) => (
+
+ typeof provider === 'string' ? provider : provider.id,
+ )
+ .join(',')}
+ />
+ ),
+ ProxiedSignInPage: (props: { provider: string }) => (
+
+ ),
+}));
+
+const renderSignIn = async (data: object) =>
+ renderInTestApp(
+
+
+ ,
+ );
+
+describe('SignInPage', () => {
+ it('renders a proxied sign-in page for a proxy provider', async () => {
+ await renderSignIn({
+ auth: { environment: 'production' },
+ signInPage: 'oauth2Proxy',
+ });
+
+ expect(screen.getByTestId('proxied-signin')).toHaveAttribute(
+ 'data-provider',
+ 'oauth2Proxy',
+ );
+ });
+
+ it('prepends the guest provider in a development environment', async () => {
+ await renderSignIn({
+ auth: { environment: 'development' },
+ signInPage: 'github',
+ });
+
+ expect(screen.getByTestId('cc-signin')).toHaveAttribute(
+ 'data-providers',
+ 'guest,github-auth-provider',
+ );
+ });
+
+ it('omits guest in production and defaults to github when unset', async () => {
+ await renderSignIn({ auth: { environment: 'production' } });
+
+ expect(screen.getByTestId('cc-signin')).toHaveAttribute(
+ 'data-providers',
+ 'github-auth-provider',
+ );
+ });
+
+ it('falls back to the default provider when configured providers are unknown', async () => {
+ await renderSignIn({
+ auth: { environment: 'production' },
+ signInPage: ['does-not-exist'],
+ });
+
+ expect(screen.getByTestId('cc-signin')).toHaveAttribute(
+ 'data-providers',
+ 'github-auth-provider',
+ );
+ });
+});