From 2c218ae143f0c0ded5df319c814daf462236685a Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 20:52:18 -0300 Subject: [PATCH 1/4] test(app): cover LearningPath API client, MenuIcon and error buttons Net-new unit/component tests for previously-untested packages/app modules (RHIDP-13853, UI coverage): - LearningPathApiClient: URL building (default + configured proxy path), bearer-token header presence/absence, and the non-ok error path. - MenuIcon: empty icon, registered system icon, inline SVG (base64 image), URL icon, and material icon name branches. - GoBackButton: navigates back when history exists, renders nothing otherwise. - ContactSupportButton: prop > configured URL > default fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/src/api/LearningPathApiClient.test.ts | 84 +++++++++++++++++++ .../ContactSupportButton.test.tsx | 58 +++++++++++++ .../errorButtons/GoBackButton.test.tsx | 55 ++++++++++++ .../app/src/components/Root/MenuIcon.test.tsx | 66 +++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 packages/app/src/api/LearningPathApiClient.test.ts create mode 100644 packages/app/src/components/ErrorPages/errorButtons/ContactSupportButton.test.tsx create mode 100644 packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx create mode 100644 packages/app/src/components/Root/MenuIcon.test.tsx 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/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..ea97afaab2 --- /dev/null +++ b/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx @@ -0,0 +1,55 @@ +import { MemoryRouter } from 'react-router-dom'; + +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { GoBackButton } from './GoBackButton'; + +const mockNavigate = jest.fn(); + +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', () => { + afterEach(() => jest.clearAllMocks()); + + 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(); + }); +}); From 32ca44403f60cac69b9a05c2441aa625ab199378 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 20:56:13 -0300 Subject: [PATCH 2/4] test(app): cover ErrorPage, ResizableDrawer and SignInPage Net-new component tests for previously-untested packages/app UI (RHIDP-13853): - ErrorPage: status/message/info rendering, the always-present contact-support action, stack-trace rendering, and no go-back action for non-404 errors. - ResizableDrawer: children render while open, resize listeners are registered when resizable, and a resize drag reports the new width. - SignInPage: proxied vs local provider selection, guest provider added in development only, default-to-github when unset or unknown. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ErrorPages/ErrorPage.test.tsx | 61 +++++++++++++ .../components/Root/ResizableDrawer.test.tsx | 66 ++++++++++++++ .../components/SignInPage/SignInPage.test.tsx | 87 +++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 packages/app/src/components/ErrorPages/ErrorPage.test.tsx create mode 100644 packages/app/src/components/Root/ResizableDrawer.test.tsx create mode 100644 packages/app/src/components/SignInPage/SignInPage.test.tsx 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..7a22a86145 --- /dev/null +++ b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx @@ -0,0 +1,61 @@ +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', () => { + 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(); + }); +}); 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..d7b1c57ad9 --- /dev/null +++ b/packages/app/src/components/Root/ResizableDrawer.test.tsx @@ -0,0 +1,66 @@ +import { renderInTestApp } from '@backstage/test-utils'; + +import { screen } from '@testing-library/react'; + +import { ResizableDrawer } from './ResizableDrawer'; + +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
+
, + ); + + // Start a drag by flipping the internal resizing flag via the handle, then + // move the mouse. Width for a right-anchored drawer is innerWidth - clientX. + const handle = document.querySelector( + '[class*="MuiBox-root"] > div:last-child', + ); + handle?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + const moveHandler = addSpy.mock.calls.find( + ([event]) => event === 'mousemove', + )?.[1] as (e: MouseEvent) => void; + globalThis.innerWidth = 1000; + moveHandler(new MouseEvent('mousemove', { clientX: 400 })); + + expect(onWidthChange).toHaveBeenCalledWith(600); + + addSpy.mockRestore(); + }); +}); 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', + ); + }); +}); From d418837376ac14991783e631ad0b11002a59db9b Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 21:04:54 -0300 Subject: [PATCH 3/4] test(app): close ErrorPage 404 and ResizableDrawer drag-end branches - ErrorPage: assert the go-back action appears for 404 errors when there is history (the 404 branch), bringing the file to 100% coverage. - ResizableDrawer: assert a mouse-up ends the drag so a later mouse-move is ignored, covering the mouse-up handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ErrorPages/ErrorPage.test.tsx | 14 ++++++++ .../components/Root/ResizableDrawer.test.tsx | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/app/src/components/ErrorPages/ErrorPage.test.tsx b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx index 7a22a86145..f27080722a 100644 --- a/packages/app/src/components/ErrorPages/ErrorPage.test.tsx +++ b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx @@ -58,4 +58,18 @@ describe('ErrorPage', () => { 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/Root/ResizableDrawer.test.tsx b/packages/app/src/components/Root/ResizableDrawer.test.tsx index d7b1c57ad9..af50acdecd 100644 --- a/packages/app/src/components/Root/ResizableDrawer.test.tsx +++ b/packages/app/src/components/Root/ResizableDrawer.test.tsx @@ -63,4 +63,40 @@ describe('ResizableDrawer', () => { addSpy.mockRestore(); }); + + it('stops resizing after mouse up', async () => { + const onWidthChange = jest.fn(); + const addSpy = jest.spyOn(globalThis, 'addEventListener'); + + await renderInTestApp( + +
drawer content
+
, + ); + + const handle = document.querySelector( + '[class*="MuiBox-root"] > div:last-child', + ); + handle?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + const findHandler = (event: string) => + addSpy.mock.calls.find(([name]) => name === event)?.[1] as ( + e: MouseEvent, + ) => void; + + // Ending the drag should make subsequent moves no-ops. + findHandler('mouseup')(new MouseEvent('mouseup')); + globalThis.innerWidth = 1000; + findHandler('mousemove')(new MouseEvent('mousemove', { clientX: 400 })); + + expect(onWidthChange).not.toHaveBeenCalled(); + + addSpy.mockRestore(); + }); }); From 9ae2bae55d106c6a5c1c09c950be11119ca2f1a4 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 21:09:23 -0300 Subject: [PATCH 4/4] test(app): harden review-flagged Layer 3 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResizableDrawer: assert the resize handle is found before dispatching (so the mouse-up test cannot pass for the wrong reason), and add a rerender-based test for the re-clamp-below-minimum branch — the file is now 100% line-covered. - ErrorPage / GoBackButton: restore window.history.length after each test so the history-dependent cases are reorder-safe. - GoBackButton: drop the redundant MemoryRouter wrapper (useNavigate is mocked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ErrorPages/ErrorPage.test.tsx | 9 +++ .../errorButtons/GoBackButton.test.tsx | 17 ++--- .../components/Root/ResizableDrawer.test.tsx | 76 +++++++++++++------ 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/packages/app/src/components/ErrorPages/ErrorPage.test.tsx b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx index f27080722a..ce104adaf3 100644 --- a/packages/app/src/components/ErrorPages/ErrorPage.test.tsx +++ b/packages/app/src/components/ErrorPages/ErrorPage.test.tsx @@ -21,6 +21,15 @@ const renderErrorPage = async (props: ErrorPageProps) => ); 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', diff --git a/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx b/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx index ea97afaab2..778f51150b 100644 --- a/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx +++ b/packages/app/src/components/ErrorPages/errorButtons/GoBackButton.test.tsx @@ -1,5 +1,3 @@ -import { MemoryRouter } from 'react-router-dom'; - import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -7,6 +5,7 @@ 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, @@ -22,15 +21,15 @@ const setHistoryLength = (length: number) => configurable: true, }); -const renderButton = () => - render( - - - , - ); +const renderButton = () => render(); describe('GoBackButton', () => { - afterEach(() => jest.clearAllMocks()); + const originalHistoryLength = window.history.length; + + afterEach(() => { + jest.clearAllMocks(); + setHistoryLength(originalHistoryLength); + }); it('navigates back when there is history to go back to', async () => { setHistoryLength(3); diff --git a/packages/app/src/components/Root/ResizableDrawer.test.tsx b/packages/app/src/components/Root/ResizableDrawer.test.tsx index af50acdecd..7e14aa5c73 100644 --- a/packages/app/src/components/Root/ResizableDrawer.test.tsx +++ b/packages/app/src/components/Root/ResizableDrawer.test.tsx @@ -1,9 +1,25 @@ import { renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; +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( @@ -46,18 +62,13 @@ describe('ResizableDrawer', () => { , ); - // Start a drag by flipping the internal resizing flag via the handle, then - // move the mouse. Width for a right-anchored drawer is innerWidth - clientX. - const handle = document.querySelector( - '[class*="MuiBox-root"] > div:last-child', - ); - handle?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - - const moveHandler = addSpy.mock.calls.find( - ([event]) => event === 'mousemove', - )?.[1] as (e: MouseEvent) => void; + // Width for a right-anchored drawer is innerWidth - clientX. + startDragFromHandle(); globalThis.innerWidth = 1000; - moveHandler(new MouseEvent('mousemove', { clientX: 400 })); + findWindowHandler( + addSpy, + 'mousemove', + )(new MouseEvent('mousemove', { clientX: 400 })); expect(onWidthChange).toHaveBeenCalledWith(600); @@ -80,23 +91,42 @@ describe('ResizableDrawer', () => { , ); - const handle = document.querySelector( - '[class*="MuiBox-root"] > div:last-child', - ); - handle?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - - const findHandler = (event: string) => - addSpy.mock.calls.find(([name]) => name === event)?.[1] as ( - e: MouseEvent, - ) => void; + startDragFromHandle(); // Ending the drag should make subsequent moves no-ops. - findHandler('mouseup')(new MouseEvent('mouseup')); + findWindowHandler(addSpy, 'mouseup')(new MouseEvent('mouseup')); globalThis.innerWidth = 1000; - findHandler('mousemove')(new MouseEvent('mousemove', { clientX: 400 })); + 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); + }); });