import {type RouteObject, RouterProvider, type To, useRouteError} from 'react-router-dom'; import {cache} from '@emotion/css'; // eslint-disable-line @emotion/no-vanilla import {CacheProvider, ThemeProvider} from '@emotion/react'; import { createMemoryHistory, createRouter, type InitialEntry, type MemoryHistory, type Router, type RouterNavigateOptions, } from '@remix-run/router'; import * as rtl from '@testing-library/react'; // eslint-disable-line no-restricted-imports import userEvent from '@testing-library/user-event'; // eslint-disable-line no-restricted-imports import * as qs from 'query-string'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {makeTestQueryClient} from 'sentry-test/queryClient'; import {GlobalDrawer} from 'sentry/components/globalDrawer'; import GlobalModal from 'sentry/components/globalModal'; import type {InjectedRouter} from 'sentry/types/legacyReactRouter'; import type {Organization} from 'sentry/types/organization'; import { DANGEROUS_SET_REACT_ROUTER_6_HISTORY, DANGEROUS_SET_TEST_HISTORY, } from 'sentry/utils/browserHistory'; import {ProvideAriaRouter} from 'sentry/utils/provideAriaRouter'; import {QueryClientProvider} from 'sentry/utils/queryClient'; import {lightTheme} from 'sentry/utils/theme'; import {OrganizationContext} from 'sentry/views/organizationContext'; import {TestRouteContext} from 'sentry/views/routeContext'; import {instrumentUserEvent} from '../instrumentedEnv/userEventIntegration'; import {initializeOrg} from './initializeOrg'; interface ProviderOptions { /** * Do not shim the router use{Routes,Router,Navigate,Location} functions, and * instead allow them to work as normal, rendering inside of a memory router. * * When enabling this passing a `router` object *will do nothing*! */ disableRouterMocks?: boolean; /** * Sets the history for the router. */ history?: MemoryHistory; /** * Sets the OrganizationContext. You may pass null to provide no organization */ organization?: Partial | null; /** * Sets the RouterContext. */ router?: Partial; } interface BaseRenderOptions extends Omit, rtl.RenderOptions { disableRouterMocks?: T; } type LocationConfig = | string | {pathname: string; query?: Record}; type RouterConfig = { /** * Sets the initial location for the router. */ location?: LocationConfig; /** * Defines a single route for the router. Necessary for testing useParams(); * * Example: * * route: '/issues/:issueId/' */ route?: string; /** * Sets the initial routes for the router. * * Defines multiple valid routes for the router. Necessary for testing * useParams() if you have multiple routes that render the same component. * * Example: * * routes: ['/issues/:issueId/', '/issues/:issueId/events/:eventId/'] */ routes?: string[]; }; type RenderOptions = T extends true ? BaseRenderOptions & {initialRouterConfig?: RouterConfig} : BaseRenderOptions; type RenderReturn = T extends true ? rtl.RenderResult & {router: TestRouter} : rtl.RenderResult; // Inject legacy react-router 3 style router mocked navigation functions // into the memory history used in react router 6 function patchBrowserHistoryMocksEnabled( history: MemoryHistory, router: InjectedRouter, any> ) { Object.defineProperty(history, 'location', {get: () => router.location}); history.replace = router.replace; history.push = (path: any) => { if (typeof path === 'object' && path.search) { path.query = qs.parse(path.search); delete path.search; delete path.hash; delete path.state; delete path.key; } // XXX(epurkhiser): This is a hack for react-router 3 to 6. react-router // 6 will not convert objects into strings before pushing. We can detect // this by looking for an empty hash, which we normally do not set for // our browserHistory.push calls if (typeof path === 'object' && path.hash === '') { const queryString = path.query ? qs.stringify(path.query) : null; path = `${path.pathname}${queryString ? `?${queryString}` : ''}`; } router.push(path); }; DANGEROUS_SET_TEST_HISTORY({ goBack: router.goBack, push: router.push, replace: router.replace, listen: jest.fn(() => {}), listenBefore: jest.fn(), getCurrentLocation: jest.fn(() => ({pathname: '', query: {}})), }); } function makeAllTheProviders(options: ProviderOptions) { const {organization, router} = initializeOrg({ organization: options.organization === null ? undefined : options.organization, router: options.router, }); // In some cases we may want to not provide an organization at all const optionalOrganization = options.organization === null ? null : organization; return function ({children}: {children?: React.ReactNode}) { const content = ( {children} ); const wrappedContent = options.disableRouterMocks ? ( content ) : ( {/* ProvideAriaRouter may not be necessary in tests but matches routes.tsx */} {content} ); if (!options.disableRouterMocks) { patchBrowserHistoryMocksEnabled(options.history ?? createMemoryHistory(), router); } return ( {wrappedContent} ); }; } function createRoutesFromConfig( children: React.ReactNode, config: RouterConfig | undefined ): RouteObject[] { // By default react-router 6 catches exceptions and displays the stack // trace. For tests we want them to bubble out function ErrorBoundary(): React.ReactNode { throw useRouteError(); } const fallbackRoute = {path: '*', element: children, errorElement: }; if (config?.route) { return [ {path: config.route, element: children, errorElement: }, fallbackRoute, ]; } if (config?.routes) { return [ ...config.routes.map(route => ({ path: route, element: children, errorElement: , })), fallbackRoute, ]; } return [{path: '*', element: children, errorElement: }]; } function makeRouter({ children, history, config, }: { children: React.ReactNode; config: RouterConfig | undefined; history: MemoryHistory; }) { const routes = createRoutesFromConfig(children, config); const router = createRouter({ future: {v7_prependBasename: true}, history, routes, }).initialize(); return router; } class TestRouter { private router: Router; constructor(router: Router) { this.router = router; } get location() { // Return parsed query params for convenience const query = qs.parse(this.router.state.location.search); return { ...this.router.state.location, query, }; } navigate = (to: To | number, opts?: RouterNavigateOptions) => { rtl.act(() => { if (typeof to === 'number') { this.router.navigate(to); } else { this.router.navigate(to, opts); } }); }; } function parseLocationConfig(location: LocationConfig | undefined): InitialEntry { if (!location) { return LocationFixture().pathname; } if (typeof location === 'string') { return location; } if (location.query) { const queryString = qs.stringify(location.query); return { pathname: location.pathname, search: queryString, }; } return location.pathname; } /** * Try avoiding unnecessary context and just mount your component. If it works, * then you dont need anything else. * * render(); * * If your component requires additional context you can pass it in the * options. * * To test route changes, pass `disableRouterMocks: true`. This will return a * `router` property which can be used to access the location or manually * navigate to a route. To set the initial location with mocks disabled, * pass an `initialRouterConfig`. */ function render( ui: React.ReactElement, options: RenderOptions = {} as RenderOptions ): RenderReturn { const initialEntry = (options.disableRouterMocks ? parseLocationConfig(options.initialRouterConfig?.location) : options.router?.location?.pathname) ?? LocationFixture().pathname; const history = createMemoryHistory({ initialEntries: [initialEntry], }); const AllTheProviders = makeAllTheProviders({ organization: options.organization, router: options.router, disableRouterMocks: options.disableRouterMocks, history, }); const memoryRouter = makeRouter({ children: {ui}, history, config: options.disableRouterMocks ? options.initialRouterConfig : undefined, }); if (options.disableRouterMocks) { DANGEROUS_SET_REACT_ROUTER_6_HISTORY(memoryRouter); } const renderResult = rtl.render(, options); const rerender = (newUi: React.ReactElement) => { const newRouter = makeRouter({ children: {newUi}, history, config: options.disableRouterMocks ? options.initialRouterConfig : undefined, }); renderResult.rerender(); }; const testRouter = new TestRouter(memoryRouter); return { ...renderResult, rerender, ...(options.disableRouterMocks ? {router: testRouter} : {}), } as RenderReturn; } /** * @deprecated * Use userEvent over fireEvent where possible. * More details: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-testing-libraryuser-event */ const fireEvent = rtl.fireEvent; function renderGlobalModal(options?: BaseRenderOptions) { const result = render(, options); /** * Helper that waits for the modal to be removed from the DOM. You may need to * wait for the modal to be removed to avoid any act warnings. */ function waitForModalToHide() { return rtl.waitFor(() => { expect(rtl.screen.queryByRole('dialog')).not.toBeInTheDocument(); }); } return {...result, waitForModalToHide}; } /** * Helper that waits for the drawer to be hidden from the DOM. You may need to * wait for the drawer to be removed to avoid any act warnings. */ function waitForDrawerToHide(ariaLabel: string) { return rtl.waitFor(() => { expect( rtl.screen.queryByRole('complementary', {name: ariaLabel}) ).not.toBeInTheDocument(); }); } /** * This cannot be implemented as a Sentry Integration because Jest creates an * isolated environment for each test suite. This means that if we were to apply * the monkey patching ahead of time, it would be shadowed by Jest. */ instrumentUserEvent(); // eslint-disable-next-line no-restricted-imports, import/export export * from '@testing-library/react'; export { // eslint-disable-next-line import/export render, renderGlobalModal, userEvent, // eslint-disable-next-line import/export fireEvent, waitForDrawerToHide, makeAllTheProviders, };