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', + ); + }); +});