/* istanbul ignore file */ /* eslint-disable react/prop-types */ /* eslint-disable import/no-extraneous-dependencies */ /** * Helper functions for writing tests. */ import React from 'react'; import { AxiosError, AxiosHeaders } from 'axios'; import { jest } from '@jest/globals'; import type { Store } from 'redux'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, type RenderResult } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { generatePath, MemoryRouter, MemoryRouterProps, Route, Routes, } from 'react-router-dom'; import * as authzApi from '@src/authz/data/api'; import { ToastContext, type ToastContextData } from './generic/toast-context'; import initializeReduxStore, { type DeprecatedReduxState } from './store'; import { getApiWaffleFlagsUrl } from './data/api'; /** @deprecated Use React Query and/or regular React Context instead of redux */ let reduxStore: Store; let queryClient: QueryClient; let axiosMock: MockAdapter; let validateUserPermissionsMock: jest.SpiedFunction; /** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */ let mockToastContext: ToastContextData = { showToast: jest.fn(), closeToast: jest.fn(), toastAction: undefined, toastMessage: null, }; export interface RouteOptions { children?: React.ReactNode; /** The URL path, like '/libraries/:libraryId' */ path?: string; /** The URL parameters, like {libraryId: 'lib:org:123'} */ params?: Record; /** and/or instead of specifying path and params, specify MemoryRouterProps */ routerProps?: MemoryRouterProps; } export interface WrapperOptions { extraWrapper?: React.FunctionComponent<{ children: React.ReactNode; }>; } /** * This component works together with the custom `render()` method we have in * this file to provide whatever react-router context you need for your * component. * * In the simplest case, you don't need to worry about the router at all, so * just render your component using `render()`. * * The next simplest way to use it is to specify `path` (the route matching rule * that is normally used to determine when to show the component or its parent * page) and `params` like this: * * ``` * render(, { path: '/library/:libraryId/*', params: { libraryId: 'lib:Axim:testlib' } }); * ``` * * In this case, components that use the `useParams` hook will get the right * library ID, and we don't even have to mock anything. * * In other cases, such as when you have routes inside routes, you'll need to * set the router's `initialEntries` (URL history) prop yourself, like this: * * ``` * render(, { * path: '/library/:libraryId/*', * // The root component is mounted on the above path, as it is in the "real" * // MFE. But to access the 'settings' sub-route/component for this test, we * // need tospecify the URL like this: * routerProps: { initialEntries: [`/library/${libraryId}/settings`] }, * }); * ``` */ const RouterAndRoute: React.FC = ({ children, path = '/', params = {}, routerProps = {}, }) => { if (Object.entries(params).length > 0 || path !== '/') { const newRouterProps = { ...routerProps }; if (!routerProps.initialEntries) { // Substitute the params into the URL so '/library/:libraryId' becomes '/library/lib:org:123' let pathWithParams = generatePath(path, params); if (pathWithParams.endsWith('/*')) { // Some routes (that contain child routes) need to end with /* in the but not in the router pathWithParams = pathWithParams.substring(0, pathWithParams.length - 1); } newRouterProps.initialEntries = [pathWithParams]; } return ( ); } return ( {children} ); }; function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions = {}) { const AllTheProviders = ({ children }) => ( {extraWrapper ? React.createElement(extraWrapper, undefined, children) : children} ); return AllTheProviders; } /** * Same as render() from `@testing-library/react` but this one provides all the * wrappers our React components need to render properly. */ function customRender(ui: React.ReactElement, options: WrapperOptions & RouteOptions = {}): RenderResult { return render(ui, { wrapper: makeWrapper(options) }); } const defaultUser = { userId: 3, username: 'abc123', administrator: true, roles: [], } as const; /** * Initialize common mocks that many of our React components will require. * * This should be called within each test case, or in `beforeEach()`. * * Returns the new `axiosMock` in case you need to mock out axios requests. */ export function initializeMocks({ user = defaultUser, initialState = undefined }: { user?: { userId: number, username: string }, initialState?: Partial, } = {}) { initializeMockApp({ authenticatedUser: user, }); reduxStore = initializeReduxStore(initialState); queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); // Many tests use waffle flags, so don't bother trying to load them from the (non-existent) // server during test runs. This avoids a lot of noisy 'Request failed with // status code 404' warnings. (Note this won't mock out course-specific requests) // // To override waffle flags for specific tests, just re-create this onGet mock // with new values within your test/beforeAll, or use mockWaffleFlags() // from src/data/apiHooks.mock.ts axiosMock.onGet(getApiWaffleFlagsUrl()).reply(200, {}); // Reset `mockToastContext` for this current test mockToastContext = { showToast: jest.fn(), closeToast: jest.fn(), toastMessage: null, toastAction: undefined, }; // Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history. jest.clearAllMocks(); // Mock user permissions to avoid breaking tests that monitor axios calls // If needed, override the mockResolvedValue in your test validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions').mockResolvedValue({}); return { reduxStore, axiosMock, mockShowToast: mockToastContext.showToast, mockToastAction: mockToastContext.toastAction, queryClient, validateUserPermissionsMock, }; } export * from '@testing-library/react'; export { customRender as render, makeWrapper }; /** Simulate a real Axios error (such as we'd see in response to a 404) */ export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) { const request = { path }; const config = { headers: new AxiosHeaders() }; const error = new AxiosError( `Mocked request failed with status code ${code}`, AxiosError.ERR_BAD_RESPONSE, config, request, { status: code, data: { detail: message }, statusText: 'error', config, headers: {}, }, ); return error; } /* * Utility to get the inner text of an element safely. */ const getInnerText = (element: Element | null): string => { if (!element) { return ''; } return (element.textContent ?? '') .split('\n') .filter((text) => text && !/^\s+$/.test(text)) .map((text) => text.trim()) .join(' '); }; export const matchInnerText = ( nodeName: string, textToMatch: string, ) => (_: string, element: Element | null) => !!element && element.nodeName === nodeName && getInnerText(element) === textToMatch;