diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a46442e5f..7dc09e23f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,6 @@ jobs: run: yarn workspace main build javascript-tests: - if: false #TODO runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 @@ -146,6 +145,7 @@ jobs: run: yarn run lint-check - name: Typecheck + if: false # To be reinstated. Anything actually used in `main` is typecheck during build run: yarn run typecheck - name: Get number of CPU cores @@ -164,7 +164,6 @@ jobs: file: coverage/lcov.info build-storybook: - if: false #TODO runs-on: ubuntu-latest steps: - name: Checkout diff --git a/frontends/api/jest.config.ts b/frontends/api/jest.config.ts index 9861f128df..8373c3a5df 100644 --- a/frontends/api/jest.config.ts +++ b/frontends/api/jest.config.ts @@ -7,13 +7,6 @@ const config: Config.InitialOptions = { ...baseConfig.setupFilesAfterEnv, "./test-utils/setupJest.ts", ], - globals: { - APP_SETTINGS: { - MITOL_AXIOS_WITH_CREDENTIALS: false, - MITOL_API_BASE_URL: "https://api.test.learn.mit.edu", - CSRF_COOKIE_NAME: "csrftoken-test", - }, - }, } export default config diff --git a/frontends/api/src/test-utils/mock-requests-example.test.ts b/frontends/api/src/test-utils/mock-requests-example.test.ts new file mode 100644 index 0000000000..3c33108f18 --- /dev/null +++ b/frontends/api/src/test-utils/mock-requests-example.test.ts @@ -0,0 +1,102 @@ +import axiosBase from "axios" +import { setMockResponse } from "./mockAxios" +import { ControlledPromise, allowConsoleErrors } from "ol-test-utilities" + +const axios = axiosBase.create() + +describe("request mocking", () => { + test("mocking specific responses and spying", async () => { + setMockResponse.post( + "/some-example", + { someResponseKey: "response for request with {a:5}" }, + { requestBody: expect.objectContaining({ a: 5 }) }, + ) + setMockResponse.post( + "/some-example", + { someResponseKey: "response for request with {b:10}" }, + { requestBody: expect.objectContaining({ b: 10 }) }, + ) + setMockResponse.post( + "/some-example", + { someResponseKey: "fallback post response" }, + // if 3rd arg is undefined, the response (2nd arg) will be used for all unmatched request bodies + ) + + setMockResponse.patch("/another-example", { someResponseKey: "patched!" }) + + const r0 = await axios.post("/some-example", { dog: "woof" }) + const r1 = await axios.patch("/another-example", { dog: "bark bark" }) + const r2 = await axios.post("/some-example", { baby: "sleep", b: 10 }) + const r3 = await axios.post("/some-example", { cat: "meow", a: 5 }) + + // toHaveBeenNthCalledWith is 1-indexed + expect(axios.post).toHaveBeenNthCalledWith(1, "/some-example", { + dog: "woof", + }) + expect(axios.patch).toHaveBeenNthCalledWith(1, "/another-example", { + dog: "bark bark", + }) + expect(axios.post).toHaveBeenNthCalledWith(2, "/some-example", { + baby: "sleep", + b: 10, + }) + expect(axios.post).toHaveBeenNthCalledWith(3, "/some-example", { + cat: "meow", + a: 5, + }) + + expect(r0.data).toEqual({ someResponseKey: "fallback post response" }) + expect(r1.data).toEqual({ someResponseKey: "patched!" }) + expect(r2.data).toEqual({ + someResponseKey: "response for request with {b:10}", + }) + expect(r3.data).toEqual({ + someResponseKey: "response for request with {a:5}", + }) + }) + + test("Error codes reject", async () => { + setMockResponse.post("/some-example", "Bad request", { code: 400 }) + await expect(axios.post("/some-example", { a: 5 })).rejects.toEqual( + expect.objectContaining({ + response: { data: "Bad request", status: 400 }, + }), + ) + }) + + test("Errors if mock value is not set.", async () => { + const { consoleError } = allowConsoleErrors() + expect(consoleError).not.toHaveBeenCalled() + let error: Error | null = null + try { + await axios.post("/some-example", { dog: "woof" }) + } catch (err) { + error = err as Error + } + expect(error?.message).toBe("No response specified for post /some-example") + expect(consoleError).toHaveBeenCalledWith( + "No response specified for post /some-example", + ) + }) + + test("Manually resolving a response", async () => { + const responseBody = new ControlledPromise() + setMockResponse.get("/respond-when-i-say", responseBody) + const response = axios.get("/respond-when-i-say") + let responseStatus = "pending" + response.then(() => { + responseStatus = "resolved" + }) + + await Promise.resolve() // flush the event queue + expect(responseStatus).toBe("pending") // response is still pending + responseBody.resolve(37) + expect(await response).toEqual( + expect.objectContaining({ + data: 37, + status: 200, + }), + ) + expect(responseStatus).toBe("resolved") + }) +}) diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 4b93e5ada2..e47e44a7e4 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -26,7 +26,7 @@ import type { import type { BaseAPI } from "../generated/v1/base" import type { BaseAPI as BaseAPIv0 } from "../generated/v0/base" -const { MITOL_API_BASE_URL: API_BASE_URL } = APP_SETTINGS +const API_BASE_URL = process.env.NEXT_PUBLIC_MITOL_API_BASE_URL // OpenAPI Generator declares parameters using interfaces, which makes passing // them to functions a little annoying. diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts index aa53284fc4..3ec45641aa 100644 --- a/frontends/jest-shared-setup.ts +++ b/frontends/jest-shared-setup.ts @@ -7,6 +7,10 @@ import * as matchers from "jest-extended" expect.extend(matchers) +// env vars +process.env.NEXT_PUBLIC_MITOL_API_BASE_URL = + "http://api.test.learn.odl.local:8063" + // Pulled from the docs - see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom Object.defineProperty(window, "matchMedia", { @@ -71,6 +75,13 @@ configure({ }, }) +jest.mock("next/navigation", () => { + return { + ...jest.requireActual("ol-test-utilities/mocks/nextNavigation") + .nextNavigationMocks, + } +}) + afterEach(() => { /** * Clear all mock call counts between tests. diff --git a/frontends/main/jest.config.ts b/frontends/main/jest.config.ts deleted file mode 100644 index 8b0f96b3aa..0000000000 --- a/frontends/main/jest.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { resolve } from "path" -import type { Config } from "@jest/types" -import baseConfig from "../jest.jsdom.config" - -const config: Config.InitialOptions = { - ...baseConfig, - setupFilesAfterEnv: [ - ...baseConfig.setupFilesAfterEnv, - resolve(__dirname, "./test-utils/setupJest.ts"), - ], - moduleNameMapper: { - "^@/(.*)$": resolve(__dirname, "src/$1"), - "^@/test-utils$": resolve(__dirname, "test-utils"), - ...baseConfig.moduleNameMapper, - }, - transformIgnorePatterns: ["/node_modules/(?!(" + "yaml", ")/)"], - globals: { - APP_SETTINGS: { - EMBEDLY_KEY: "embedly_key", - MITOL_API_BASE_URL: "https://api.test.learn.mit.edu", - PUBLIC_URL: "", - SITE_NAME: "MIT Learn", - }, - }, -} - -export default config diff --git a/frontends/main/package.json b/frontends/main/package.json index 1d1e27e1e8..9fd961f258 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -26,7 +26,6 @@ "react": "^18", "react-dom": "^18", "react-slick": "^0.30.2", - "sharp": "^0.33.4", "slick-carousel": "^1.8.1", "tiny-invariant": "^1.3.3", "yup": "^1.4.0" @@ -39,8 +38,8 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.7", "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", "@types/react-slick": "^0.23.13", "@types/slick-carousel": "^1", "eslint": "8.57.0", diff --git a/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx b/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx index 8d90055e8d..cfc5b4c93f 100644 --- a/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx +++ b/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx @@ -72,7 +72,7 @@ const AUTH_TEXT_DATA = { text: "As a member, get personalized recommendations, curate learning lists, and follow your areas of interest.", linkProps: { children: "Sign Up for Free", - reloadDocument: true, + rawAnchor: true, href: urls.login({ pathname: urls.DASHBOARD_HOME, }), diff --git a/frontends/main/src/page-components/Header/Header.tsx b/frontends/main/src/page-components/Header/Header.tsx index ff749b9b3c..d71a606912 100644 --- a/frontends/main/src/page-components/Header/Header.tsx +++ b/frontends/main/src/page-components/Header/Header.tsx @@ -125,7 +125,7 @@ const SearchButton: FunctionComponent = () => { diff --git a/frontends/main/src/page-components/Header/UserMenu.tsx b/frontends/main/src/page-components/Header/UserMenu.tsx index 092a7bed6a..3cb8535f46 100644 --- a/frontends/main/src/page-components/Header/UserMenu.tsx +++ b/frontends/main/src/page-components/Header/UserMenu.tsx @@ -162,7 +162,7 @@ const UserMenu: React.FC = ({ variant }) => { Log In @@ -177,7 +177,7 @@ const UserMenu: React.FC = ({ variant }) => { data-testid="login-button-mobile" edge="circular" variant="text" - reloadDocument={true} + rawAnchor={true} href={loginUrl} aria-label="Log in" > diff --git a/frontends/mit-learn/jest.config.ts b/frontends/mit-learn/jest.config.ts deleted file mode 100644 index c767e8583f..0000000000 --- a/frontends/mit-learn/jest.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import path from "path" -import type { Config } from "@jest/types" -import baseConfig from "../jest.jsdom.config" - -const config: Config.InitialOptions = { - ...baseConfig, - setupFilesAfterEnv: [ - ...baseConfig.setupFilesAfterEnv, - "./test-utils/setupJest.ts", - ], - moduleNameMapper: { - "^@/(.*)$": path.resolve(__dirname, "src/$1"), - ...baseConfig.moduleNameMapper, - }, - transformIgnorePatterns: ["/node_modules/(?!(" + "yaml", ")/)"], - globals: { - APP_SETTINGS: { - EMBEDLY_KEY: "embedly_key", - MITOL_API_BASE_URL: "https://api.test.learn.mit.edu", - PUBLIC_URL: "", - SITE_NAME: "MIT Learn", - }, - }, -} - -export default config diff --git a/frontends/ol-ckeditor/package.json b/frontends/ol-ckeditor/package.json index 2b703aff90..c4daaa5ede 100644 --- a/frontends/ol-ckeditor/package.json +++ b/frontends/ol-ckeditor/package.json @@ -38,6 +38,7 @@ "ol-utilities": "0.0.0" }, "devDependencies": { + "@faker-js/faker": "^8.0.0", "@testing-library/react": "16.0.1" } } diff --git a/frontends/ol-ckeditor/src/components/cloudServices.test.ts b/frontends/ol-ckeditor/src/components/cloudServices.test.ts index fd5c8ccfe6..cb2ee13ccc 100644 --- a/frontends/ol-ckeditor/src/components/cloudServices.test.ts +++ b/frontends/ol-ckeditor/src/components/cloudServices.test.ts @@ -1,9 +1,15 @@ import getCloudServicesConfig from "./cloudServices" import axios from "axios" +import { faker } from "@faker-js/faker/locale/en" jest.mock("axios") describe("cloudServicesConfig", () => { + const uploadUrl = faker.internet.url() + beforeAll(() => { + process.env.NEXT_PUBLIC_CKEDITOR_UPLOAD_URL = uploadUrl + }) + test("tokenUrl queries correct API", async () => { const cloud = getCloudServicesConfig() const mockGet = axios.get as jest.Mock @@ -12,9 +18,8 @@ describe("cloudServicesConfig", () => { expect(token).toBe("the-cool-token") }) - test("CKEDITOR_UPLOAD_URL is set from global APP_SETTINGS", () => { - APP_SETTINGS.CKEDITOR_UPLOAD_URL = "https://meowmeow.com" + test("CKEDITOR_UPLOAD_URL is set from env", () => { const cloud = getCloudServicesConfig() - expect(cloud.uploadUrl).toBe("https://meowmeow.com") + expect(cloud.uploadUrl).toBe(uploadUrl) }) }) diff --git a/frontends/ol-ckeditor/src/components/cloudServices.ts b/frontends/ol-ckeditor/src/components/cloudServices.ts index 7399e95bdf..d24cfa6cf7 100644 --- a/frontends/ol-ckeditor/src/components/cloudServices.ts +++ b/frontends/ol-ckeditor/src/components/cloudServices.ts @@ -3,7 +3,7 @@ import axios from "axios" const cloudServicesConfig = () => ({ - uploadUrl: APP_SETTINGS.CKEDITOR_UPLOAD_URL, + uploadUrl: process.env.NEXT_PUBLIC_CKEDITOR_UPLOAD_URL, tokenUrl: async () => { const { data } = await axios.get("/api/v0/ckeditor/") return data.token as string diff --git a/frontends/ol-components/.storybook/main.ts b/frontends/ol-components/.storybook/main.ts index 2b2fb4feea..83d1aadaca 100644 --- a/frontends/ol-components/.storybook/main.ts +++ b/frontends/ol-components/.storybook/main.ts @@ -1,7 +1,7 @@ import { resolve, join, dirname } from "path" import * as dotenv from "dotenv" import * as webpack from "webpack" -import { StorybookConfig } from '@storybook/nextjs'; +import { StorybookConfig } from "@storybook/nextjs" dotenv.config({ path: resolve(__dirname, "../../../.env") }) @@ -28,12 +28,12 @@ const config: StorybookConfig = { getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@storybook/addon-interactions"), getAbsolutePath("@storybook/addon-webpack5-compiler-swc"), - getAbsolutePath("@storybook/addon-mdx-gfm") + getAbsolutePath("@storybook/addon-mdx-gfm"), ], - framework: { + framework: { name: getAbsolutePath("@storybook/nextjs"), - options: {} + options: {}, }, docs: {}, @@ -48,7 +48,6 @@ const config: StorybookConfig = { }), ) - /* Fix for this error: Module not found: Error: Can't resolve 'react-dom/test-utils' in './node_modules/@testing-library/react/dist/@testing-library' @@ -60,20 +59,20 @@ const config: StorybookConfig = { We should not use @storybook packages in ol-test-utilities or anywhere outside of ol-components as they are not related so below we are aliasing @testing-library/react. */ - config.resolve = { - ...config.resolve, - alias: { - ...config.resolve?.alias, - "@testing-library/react": "@storybook/test" - } - } + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + "@testing-library/react": "@storybook/test", + }, + } return config }, typescript: { - reactDocgen: "react-docgen-typescript" - } + reactDocgen: "react-docgen-typescript", + }, } export default config diff --git a/frontends/ol-components/src/components/Button/Button.test.tsx b/frontends/ol-components/src/components/Button/Button.test.tsx index 9962263bf4..5914b34eca 100644 --- a/frontends/ol-components/src/components/Button/Button.test.tsx +++ b/frontends/ol-components/src/components/Button/Button.test.tsx @@ -4,73 +4,70 @@ import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import { ButtonLink, ActionButtonLink } from "./Button" import Link from "next/link" -// Mock react-router-dom's Link so we don't need to set up a Router -jest.mock("react-router-dom", () => { +jest.mock("next/link", () => { + const Actual = jest.requireActual("next/link") return { - Link: jest.fn((props) => {props.children}), + __esModule: true, + default: jest.fn((args) => ), } }) describe("ButtonLink", () => { test.each([ { - reloadDocument: undefined, + rawAnchor: undefined, label: "Link", }, { - reloadDocument: false, + rawAnchor: false, label: "Link", }, { - reloadDocument: true, + rawAnchor: true, label: "Anchor", }, - ])( - "renders with reloadDocument if reloadDocument=$reloadDocument", - ({ reloadDocument }) => { - render( - - Link text here - , - { wrapper: ThemeProvider }, - ) - screen.getByRole("link") - expect(Link).toHaveBeenCalledWith( - expect.objectContaining({ reloadDocument }), - expect.anything(), - ) - }, - ) + ])("renders with anchor tag if rawAnchor=$rawAnchor", ({ rawAnchor }) => { + render( + + Link text here + , + { wrapper: ThemeProvider }, + ) + screen.getByRole("link") + if (rawAnchor) { + expect(Link).not.toHaveBeenCalled() + } else { + expect(Link).toHaveBeenCalled() + } + }) }) describe("ActionButtonLink", () => { test.each([ { - reloadDocument: undefined, + rawAnchor: undefined, label: "Link", }, { - reloadDocument: false, + rawAnchor: false, label: "Link", }, { - reloadDocument: true, + rawAnchor: true, label: "Anchor", }, - ])( - "renders with reloadDocument if reloadDocument=$reloadDocument", - ({ reloadDocument }) => { - render( - - Link text here - , - { wrapper: ThemeProvider }, - ) - screen.getByRole("link") - expect(Link).toHaveBeenCalledWith( - expect.objectContaining({ reloadDocument }), - expect.anything(), - ) - }, - ) + ])("renders with rawAnchor if rawAnchor=$rawAnchor", ({ rawAnchor }) => { + render( + + Link text here + , + { wrapper: ThemeProvider }, + ) + screen.getByRole("link") + if (rawAnchor) { + expect(Link).not.toHaveBeenCalled() + } else { + expect(Link).toHaveBeenCalled() + } + }) }) diff --git a/frontends/ol-components/src/components/Button/Button.tsx b/frontends/ol-components/src/components/Button/Button.tsx index 6a41a8fe3a..753092aa36 100644 --- a/frontends/ol-components/src/components/Button/Button.tsx +++ b/frontends/ol-components/src/components/Button/Button.tsx @@ -31,6 +31,17 @@ type ButtonStyleProps = { responsive?: boolean } +const styleProps: Record = { + variant: true, + size: true, + edge: true, + startIcon: true, + endIcon: true, + responsive: true, +} satisfies Record + +const shouldForwardProp = (prop: string) => !styleProps[prop] + const defaultProps: Required> = { variant: "primary", @@ -71,7 +82,9 @@ const sizeStyles = (size: ButtonSize, hasBorder: boolean, theme: Theme) => { ] } -const ButtonStyled = styled.button((props) => { +const ButtonStyled = styled("button", { shouldForwardProp })(( + props, +) => { const { size, variant, edge, theme, color, responsive } = { ...defaultProps, ...props, @@ -231,14 +244,6 @@ const IconContainer = styled.span<{ side: "start" | "end"; size: ButtonSize }>( ], ) -const LinkStyled = styled(ButtonStyled.withComponent(Link), { - /** - * There are no extra styles here, emotion seems to forward "responsive" - * to the underlying dom node without this. - */ - shouldForwardProp: (prop) => prop !== "responsive", -})({}) - type ButtonProps = ButtonStyleProps & React.ComponentProps<"button"> const ButtonInner: React.FC< @@ -274,28 +279,21 @@ const Button = React.forwardRef( ) type ButtonLinkProps = ButtonStyleProps & - React.ComponentProps<"a"> & { - href?: string - /** - * If true, the component will skip client-side routing and reload the - * document as if it were ``. - * - * See https://reactrouter.com/en/main/components/link - */ - reloadDocument?: boolean + Omit, "as"> & { + rawAnchor?: boolean + href: string } - -const ButtonLink = React.forwardRef( - ({ children, href = "", endIcon, ...props }, ref) => { +const ButtonLink = ButtonStyled.withComponent( + ({ children, rawAnchor, ...props }: ButtonLinkProps) => { + const Component = rawAnchor ? "a" : Link return ( - - - {children} - - + + {children} + ) }, ) +ButtonLink.displayName = "ButtonLink" type ActionButtonProps = Omit & React.ComponentProps<"button"> @@ -346,22 +344,23 @@ const ActionButton = styled( }) type ActionButtonLinkProps = ActionButtonProps & - React.ComponentProps<"a"> & - Pick - + Omit, "as"> & { + rawAnchor?: boolean + href: string + } const ActionButtonLink = ActionButton.withComponent( - React.forwardRef( - ({ href = "", ...props }, ref) => { - return - }, - ), + ({ rawAnchor, ...props }: ButtonLinkProps) => { + const Component = rawAnchor ? "a" : Link + return + }, ) +ActionButtonLink.displayName = "ActionButtonLink" export { Button, ButtonLink, ActionButton, ActionButtonLink } + export type { ButtonProps, ButtonLinkProps, - ButtonStyleProps, ActionButtonProps, ActionButtonLinkProps, } diff --git a/frontends/ol-components/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx index ce521c464f..a087bedb6d 100644 --- a/frontends/ol-components/src/components/Card/Card.test.tsx +++ b/frontends/ol-components/src/components/Card/Card.test.tsx @@ -1,6 +1,8 @@ import { render } from "@testing-library/react" import { Card } from "./Card" import React from "react" +import { getOriginalSrc } from "ol-test-utilities" +import invariant from "tiny-invariant" describe("Card", () => { test("has class MitCard-root on root element", () => { @@ -15,15 +17,21 @@ describe("Card", () => { ) const card = container.firstChild as HTMLElement const title = card.querySelector(".MitCard-title") - const image = card.querySelector(".MitCard-image") + const image = card.querySelector(".MitCard-image") const info = card.querySelector(".MitCard-info") const footer = card.querySelector(".MitCard-footer") const actions = card.querySelector(".MitCard-actions") + invariant(card) + invariant(title) + invariant(image) + invariant(info) + invariant(footer) + invariant(actions) expect(card).toHaveClass("MitCard-root") expect(card).toHaveClass("Foo") expect(title).toHaveTextContent("Title") - expect(image).toHaveAttribute("src", "https://via.placeholder.com/150") + expect(getOriginalSrc(image)).toBe("https://via.placeholder.com/150") expect(image).toHaveAttribute("alt", "placeholder") expect(info).toHaveTextContent("Info") expect(footer).toHaveTextContent("Footer") diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index 57a493f7af..5b2507a6f8 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -2,22 +2,15 @@ import React from "react" import { screen, render } from "@testing-library/react" import { LearningResourceCard } from "./LearningResourceCard" import type { LearningResourceCardProps } from "./LearningResourceCard" -import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" +import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { ResourceTypeEnum, PlatformEnum, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { getByImageSrc } from "ol-test-utilities" const setup = (props: LearningResourceCardProps) => { // TODO Browser Router will need to be replaced with a Next.js router mock or alternative strategy - return render( - // @ts-expect-error TODO fix react-router tests - // eslint-disable-next-line react/jsx-no-undef - - - {/* @ts-expect-error TODO fix react-router tests */} - , - { wrapper: ThemeProvider }, - ) + return render(, { wrapper: ThemeProvider }) } describe("Learning Resource Card", () => { @@ -118,16 +111,11 @@ describe("Learning Resource Card", () => { const onAddToUserListClick = jest.fn() render( - // @ts-expect-error TODO fix react-router tests - // eslint-disable-next-line react/jsx-no-undef - - - {/* @ts-expect-error TODO fix react-router tests */} - , + , { wrapper: ThemeProvider }, ) @@ -180,42 +168,23 @@ describe("Learning Resource Card", () => { test.each([ { image: null, - expected: { src: DEFAULT_RESOURCE_IMG, alt: "", role: "presentation" }, + expected: { src: DEFAULT_RESOURCE_IMG, alt: "" }, }, { image: { url: "https://example.com/image.jpg", alt: "An image" }, - expected: { - src: "https://example.com/image.jpg", - alt: "An image", - role: "img", - }, + expected: { src: "https://example.com/image.jpg", alt: "An image" }, }, { image: { url: "https://example.com/image.jpg", alt: null }, - expected: { - src: "https://example.com/image.jpg", - alt: "", - role: "presentation", - }, + expected: { src: "https://example.com/image.jpg", alt: "" }, }, ])("Image is displayed if present", ({ expected, image }) => { const resource = factories.learningResources.resource({ image }) - setup({ resource }) + const view = setup({ resource }) - const imageEls = screen.getAllByRole(expected.role) - - const matching = imageEls.filter((im) => - expected.src === DEFAULT_RESOURCE_IMG - ? im.src === DEFAULT_RESOURCE_IMG - : im.src === - embedlyCroppedImage(expected.src, { - width: 298, - height: 170, - key: "fake-embedly-key", - }), - ) - expect(matching.length).toBe(1) - expect(matching[0]).toHaveAttribute("alt", expected.alt) + const imageEl = getByImageSrc(view.container, expected.src) + + expect(imageEl).toHaveAttribute("alt", expected.alt) }) }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index c855e4d92a..dd29978299 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -11,7 +11,6 @@ import { LearningResource } from "api" import { formatDate, getReadableResourceType, - embedlyCroppedImage, DEFAULT_RESOURCE_IMG, getLearningResourcePrices, getResourceDate, @@ -57,20 +56,6 @@ const getImageDimensions = (size: Size, isMedia: boolean) => { return dimensions[size] } -const getEmbedlyUrl = ( - resource: LearningResource, - size: Size, - isMedia: boolean, -) => { - if (!process.env.NEXT_PUBLIC_EMBEDLY_KEY!) { - return resource.image!.url! - } - return embedlyCroppedImage(resource.image!.url!, { - key: process.env.NEXT_PUBLIC_EMBEDLY_KEY!, - ...getImageDimensions(size, isMedia), - }) -} - type ResourceIdCallback = ( event: React.MouseEvent, resourceId: number, @@ -247,11 +232,7 @@ const LearningResourceCard: React.FC = ({ return ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index 9189cb40d1..e9f28e8437 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -3,10 +3,11 @@ import { BrowserRouter } from "react-router-dom" import { screen, render } from "@testing-library/react" import { LearningResourceListCard } from "./LearningResourceListCard" import type { LearningResourceListCardProps } from "./LearningResourceListCard" -import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" +import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { ResourceTypeEnum, PlatformEnum, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { getByImageSrc } from "ol-test-utilities" const setup = (props: LearningResourceListCardProps) => { return render( @@ -186,22 +187,11 @@ describe("Learning Resource List Card", () => { ])("Image is displayed if present", ({ expected, image }) => { const resource = factories.learningResources.resource({ image }) - setup({ resource }) + const view = setup({ resource }) - const imageEls = screen.getAllByRole(expected.role) - - const matching = imageEls.filter((im) => - expected.src === DEFAULT_RESOURCE_IMG - ? im.src === DEFAULT_RESOURCE_IMG - : im.src === - embedlyCroppedImage(expected.src, { - width: 116, - height: 104, - key: "fake-embedly-key", - }), - ) - expect(matching.length).toBe(1) - expect(matching[0]).toHaveAttribute("alt", expected.alt) + const imageEl = getByImageSrc(view.container, expected.src) + + expect(imageEl).toHaveAttribute("alt", expected.alt) }) describe("Price display", () => { diff --git a/frontends/ol-components/src/components/Link/Link.tsx b/frontends/ol-components/src/components/Link/Link.tsx index ca12c172cb..fe39218faa 100644 --- a/frontends/ol-components/src/components/Link/Link.tsx +++ b/frontends/ol-components/src/components/Link/Link.tsx @@ -54,7 +54,7 @@ const linkStyles = (props: LinkStyleProps) => { type LinkProps = LinkStyleProps & React.ComponentProps<"a"> & { - reloadDocument?: boolean + rawAnchor?: boolean } /** diff --git a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.test.tsx b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.test.tsx index 091de4afac..321f0da958 100644 --- a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.test.tsx +++ b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.test.tsx @@ -1,5 +1,3 @@ -import { RouterProvider } from "react-router" -import { createBrowserRouter } from "react-router-dom" import { RoutedDrawer } from "./RoutedDrawer" import type { RoutedDrawerProps } from "./RoutedDrawer" import { @@ -10,6 +8,7 @@ import { import user from "@testing-library/user-event" import React from "react" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { mockRouter } from "ol-test-utilities/mocks/nextNavigation" const TestDrawerContents = ({ closeDrawer }: { closeDrawer: () => void }) => (
@@ -27,26 +26,13 @@ const renderRoutedDrawer =

( initialSearchParams: string, initialHashParams: string, ) => { + mockRouter.setCurrentUrl(`/?${initialSearchParams}#${initialHashParams}`) + window.location.hash = initialHashParams const childFn = jest.fn(TestDrawerContents) - const router = createBrowserRouter( - [ - { - path: "*", - element: {childFn}, - }, - ], - {}, - ) - router.navigate(`${initialSearchParams}${initialHashParams}`) - render(, { + render({childFn}, { wrapper: ThemeProvider, }) - const location = { - get current() { - return router.state.location - }, - } - return { location, childFn } + return { childFn } } describe("RoutedDrawer", () => { @@ -54,19 +40,19 @@ describe("RoutedDrawer", () => { { params: ["a", "b", "c"], requiredParams: ["a", "b"], - initialSearch: "?a=1", + initialSearch: "a=1", called: false, }, { params: ["a", "b"], requiredParams: ["a", "b"], - initialSearch: "?a=1&b=2", + initialSearch: "a=1&b=2", called: true, }, { params: ["a", "b", "c"], requiredParams: ["a", "b"], - initialSearch: "?a=1&b=2", + initialSearch: "a=1&b=2", called: true, }, ])( @@ -85,7 +71,7 @@ describe("RoutedDrawer", () => { { params: ["a", "b", "c"], requiredParams: ["a", "b"], - initialSearch: "?a=1&b=2&c=3&d=4", + initialSearch: "a=1&b=2&c=3&d=4", childProps: { params: { a: "1", b: "2", c: "3" }, closeDrawer: expect.any(Function), @@ -94,7 +80,7 @@ describe("RoutedDrawer", () => { { params: ["a", "b", "c"], requiredParams: ["a", "b"], - initialSearch: "?a=1&b=2&d=4", + initialSearch: "a=1&b=2&d=4", childProps: { params: { a: "1", b: "2", c: null }, closeDrawer: expect.any(Function), @@ -115,44 +101,37 @@ describe("RoutedDrawer", () => { it("Includes a close button that closes drawer", async () => { const params = ["a"] const requiredParams = ["a"] - const initialSearch = "?a=1" - const { location } = renderRoutedDrawer( - { params, requiredParams }, - initialSearch, - "", - ) + const initialSearch = "a=1" + renderRoutedDrawer({ params, requiredParams }, initialSearch, "") const content = getDrawerContent() + await user.click(screen.getByRole("button", { name: "CloseFn" })) await waitForElementToBeRemoved(content) - expect(location.current.search).toBe("") + expect(mockRouter.query).toEqual({}) }) it("Passes a closeDrawer callback to child that can close the drawer", async () => { const params = ["a"] const requiredParams = ["a"] - const initialSearch = "?a=1" - const { location } = renderRoutedDrawer( - { params, requiredParams }, - initialSearch, - "", - ) + const initialSearch = "a=1" + renderRoutedDrawer({ params, requiredParams }, initialSearch, "") const content = getDrawerContent() await user.click(screen.getByRole("button", { name: "Close" })) await waitForElementToBeRemoved(content) - expect(location.current.search).toBe("") + expect(mockRouter.query).toEqual({}) }) it("Restores any hash params that were in the initial request", async () => { const params = ["a"] const requiredParams = ["a"] - const initialSearch = "?a=1" - const initialHashParams = "#test=1" - const { location } = renderRoutedDrawer( + const initialSearch = "a=1" + const initialHashParams = "test" + renderRoutedDrawer( { params, requiredParams }, initialSearch, initialHashParams, @@ -162,7 +141,6 @@ describe("RoutedDrawer", () => { await user.click(screen.getByRole("button", { name: "CloseFn" })) await waitForElementToBeRemoved(content) - - expect(location.current.hash).toBe(initialHashParams) + expect(mockRouter.hash).toBe(`#${initialHashParams}`) }) }) diff --git a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx index 63b9eb36e9..b09a35b7fa 100644 --- a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx +++ b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx @@ -7,7 +7,6 @@ import { RiCloseLargeLine } from "@remixicon/react" import { useSearchParams, useRouter, - usePathname, ReadonlyURLSearchParams, } from "next/navigation" import { useToggle } from "ol-utilities" @@ -46,7 +45,6 @@ const RoutedDrawer = ( const [open, setOpen] = useToggle(false) const searchParams = useSearchParams() - const pathname = usePathname() const router = useRouter() const childParams = useMemo(() => { @@ -55,6 +53,16 @@ const RoutedDrawer = ( ) as Record }, [searchParams, params]) + /** + * `requiredArePresnet` and `open` are usually the same, except when the + * drawer is in the process of closing. + * - `open` changes to false when the drawer begins closing + * - URL Params are updated when the drawer finishes closing, changing the + * value of `requiredArePresent` + * + * This means that if content within the drawer depends on the search params, + * then the content will remain visible during the closing animation. + */ const requiredArePresent = requiredParams.every( (name) => childParams[name] !== null, ) @@ -73,12 +81,11 @@ const RoutedDrawer = ( params.forEach((param) => { newSearchParams.delete(param) }) - return newSearchParams.toString() + return newSearchParams } const newParams = getNewParams(searchParams) - - router.push(`${pathname}${newParams ? `?${newParams}` : ""}`) - }, [router, searchParams, pathname, params]) + router.push(`?${newParams}${window.location.hash}`) + }, [router, searchParams, params]) return ( { +jest.mock("next/link", () => { return { - Link: React.forwardRef( + __esModule: true, + default: React.forwardRef( jest.fn(({ children, ...props }, ref) => { return ( - + {children} ) @@ -112,7 +108,7 @@ describe("SimpleMenu", () => { visibilityHandler.mockClear() }) - it("Renders link items using React Router's Link", async () => { + it("Renders link items using link", async () => { const items: SimpleMenuItem[] = [ { key: "one", label: "Item 1", onClick: jest.fn() }, { key: "two", label: "Item 2", href: "./woof" }, @@ -123,8 +119,8 @@ describe("SimpleMenu", () => { }) await user.click(screen.getByRole("button", { name: "Open Menu" })) const item2 = screen.getByRole("menuitem", { name: "Item 2" }) - expect(item2.dataset.reactComponent).toBe("react-router-dom-link") - expect(item2.dataset.propTo).toBe("./woof") + expect(item2.dataset.reactComponent).toBe("next/link") + expect(item2).toHaveAttribute("href", "./woof") }) it("Renders link with custom LinkComponent if specified", async () => { diff --git a/frontends/ol-components/src/components/SimpleMenu/SimpleMenu.tsx b/frontends/ol-components/src/components/SimpleMenu/SimpleMenu.tsx index 48bb54a1ef..8af748d22f 100644 --- a/frontends/ol-components/src/components/SimpleMenu/SimpleMenu.tsx +++ b/frontends/ol-components/src/components/SimpleMenu/SimpleMenu.tsx @@ -3,19 +3,6 @@ import Menu, { MenuProps } from "@mui/material/Menu" import { MenuItem } from "../MenuItem/MenuItem" import ListItemIcon from "@mui/material/ListItemIcon" import { default as RouterLink } from "next/link" -import type { LinkProps as RouterLinkProps } from "next/link" - -/** - * See https://mui.com/material-ui/guides/routing/#global-theme-link - */ -const LinkBehavior = React.forwardRef< - HTMLAnchorElement, - { href: RouterLinkProps["href"] } ->((props, ref) => { - const { href, ...other } = props - // Map href (Material UI) -> to (react-router) - return -}) interface SimpleMenuItemBase { key: string @@ -107,7 +94,7 @@ const SimpleMenu: React.FC = ({ * - https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links/ * shows a more correct implementation. */ - component: item.LinkComponent ?? LinkBehavior, + component: item.LinkComponent ?? RouterLink, href: item.href, } : {} diff --git a/frontends/ol-components/src/components/TabButtons/TabButtonList.stories.tsx b/frontends/ol-components/src/components/TabButtons/TabButtonList.stories.tsx index bb3981bf7e..50c2d65089 100644 --- a/frontends/ol-components/src/components/TabButtons/TabButtonList.stories.tsx +++ b/frontends/ol-components/src/components/TabButtons/TabButtonList.stories.tsx @@ -10,7 +10,7 @@ import Typography from "@mui/material/Typography" import { faker } from "@faker-js/faker/locale/en" import Container from "@mui/material/Container" import { TabListProps } from "@mui/lab/TabList" -import { usePathname } from "next/navigation"; +import { usePathname } from "next/navigation" type StoryProps = TabListProps & { count: number @@ -93,17 +93,17 @@ export const LinkTabs: Story = { nextjs: { appDirectory: true, navigation: { - pathname: "/#link2" - } - } + pathname: "/#link2", + }, + }, }, render: () => { - const pathname = usePathname(); + const pathname = usePathname() const [hash, setHash] = useState() - useEffect(()=>{ + useEffect(() => { setHash(pathname.match(/(#.+)/)?.[0]) - }, [ pathname ]); + }, [pathname]) return (

diff --git a/frontends/ol-components/src/components/TabButtons/TabButtonList.tsx b/frontends/ol-components/src/components/TabButtons/TabButtonList.tsx index a0b53c6df2..ec13a5b475 100644 --- a/frontends/ol-components/src/components/TabButtons/TabButtonList.tsx +++ b/frontends/ol-components/src/components/TabButtons/TabButtonList.tsx @@ -70,7 +70,8 @@ const TabLinkInner = React.forwardRef( const TabButton = (props: TabProps<"button">) => ( ) -const TabButtonLink = (props: TabProps<"a">) => ( + +const TabButtonLink = ({ ...props }: TabProps) => ( ) diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 22e54b3c2b..e94b4db35b 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -28,17 +28,13 @@ export type { export { Button, + ButtonLink, ActionButton, ActionButtonLink, - ButtonLink, } from "./components/Button/Button" export { ListCard, ListCardActionButton } from "./components/Card/ListCard" -export type { - ButtonProps, - ButtonLinkProps, - ButtonStyleProps, -} from "./components/Button/Button" +export type { ButtonProps, ButtonLinkProps } from "./components/Button/Button" export { default as MuiCard } from "@mui/material/Card" export type { CardProps as MuiCardProps } from "@mui/material/Card" diff --git a/frontends/ol-test-utilities/package.json b/frontends/ol-test-utilities/package.json index e548f57a79..dd8a0d9d7a 100644 --- a/frontends/ol-test-utilities/package.json +++ b/frontends/ol-test-utilities/package.json @@ -4,13 +4,15 @@ "private": true, "exports": { ".": "./src/index.ts", - "./filemocks/*": "./src/filemocks/*" + "./filemocks/*": "./src/filemocks/*", + "./mocks/nextNavigation": "./src/mocks/nextNavigation.ts" }, "dependencies": { "@faker-js/faker": "^8.0.0", "@testing-library/react": "16.0.1", "css-mediaquery": "^0.1.2", "dom-accessibility-api": "^0.7.0", + "next-router-mock": "^0.9.13", "tiny-invariant": "^1.3.1" } } diff --git a/frontends/ol-test-utilities/src/domQueries/byImageSrc.ts b/frontends/ol-test-utilities/src/domQueries/byImageSrc.ts new file mode 100644 index 0000000000..bfa38615b0 --- /dev/null +++ b/frontends/ol-test-utilities/src/domQueries/byImageSrc.ts @@ -0,0 +1,145 @@ +/** + * Custom queries for finding image elements by src. + * + * src assertions could be handled via getByRole("img"), but that approach has + * some limitations: + * - asserting about src is not straightforward if there are multiple images + * - image elements may have role "presentation" if they have no alt text + * + * Additionally, some images may be optimized by NextJS, in which case the + * original src url is encoded. + */ + +import { buildQueries, isInaccessible } from "@testing-library/react" +import invariant from "tiny-invariant" +import type { GetErrorFunction } from "@testing-library/react" + +/** + * Get the original src of an image element, which may be encoded in a NextJS optimized image src. + */ +const getOriginalSrc = (el: HTMLImageElement) => { + const src = el.src + if (new URL(src).pathname.startsWith("/_next")) { + const url = new URL(src).searchParams.get("url") + invariant(url, `Expected url query param in ${src}`) + return decodeURIComponent(url) + } + return src +} + +type QueryAllByImageSrcOpts = { + /** + * Defaults to `true`. + * If `true`, will decode NextJS optimized image srcs. + */ + nextJsOriginalSrc?: boolean + /** + * Defaults to `false`. + * If `true`, elements normally hidden from accessibility tree will be included. + * See https://testing-library.com/docs/queries/byrole/#hidden + */ + hidden?: boolean +} +const DEFAULT_BY_IMAGE_SRC_OPTS: QueryAllByImageSrcOpts = { + nextJsOriginalSrc: true, + hidden: false, +} + +/** + * Get 0+ image element matching specified src. + * NOTE: decodes NextJS optimized image src by default + */ +const queryAllByImageSrc = ( + c: HTMLElement, + src: string | RegExp, + opts?: QueryAllByImageSrcOpts, +): HTMLElement[] => { + const { nextJsOriginalSrc, hidden } = { + ...DEFAULT_BY_IMAGE_SRC_OPTS, + ...opts, + } + // Don't query by role, which could be "img" or "presentation" depending on alt text presence + // but do check that the element is not otherwise inaccessible + const images = Array.from(c.querySelectorAll("img")) + .filter((el) => { + return hidden || !isInaccessible(el) + }) + .filter((el) => { + invariant(el instanceof HTMLImageElement, "Expected HTMLImageElement") + const elSrc = nextJsOriginalSrc ? getOriginalSrc(el) : el.src + return typeof src === "string" ? elSrc === src : src.test(elSrc) + }) + return images +} +const getImageSrcs = (c: HTMLElement, opts?: QueryAllByImageSrcOpts) => { + const { hidden, nextJsOriginalSrc } = { + ...DEFAULT_BY_IMAGE_SRC_OPTS, + ...opts, + } + return Array.from(c.querySelectorAll("img")) + .filter((el) => { + return hidden || !isInaccessible(el) + }) + .map((el) => (nextJsOriginalSrc ? getOriginalSrc(el) : el.src)) +} + +const getMultipleError: GetErrorFunction = ( + c, + src: string | RegExp, + opts?: QueryAllByImageSrcOpts, +) => { + invariant(c instanceof HTMLElement, "Container should be an HTMLElement") + const srcs = getImageSrcs(c, opts).join("\n\t") + return `Found multiple elements matching src.\nExpected rrc:\n\t${src}\nFound srcs:\n\t${srcs}` +} +const getMissingError: GetErrorFunction = ( + c, + src: string | RegExp, + opts?: QueryAllByImageSrcOpts, +) => { + invariant(c instanceof HTMLElement, "Container should be an HTMLElement") + const srcs = getImageSrcs(c, opts).join("\n") + return `Found zero elements matching src.\nExpected src:\n\t${src}\nFound srcs:\n\t${srcs}` +} + +const byImageSrc = buildQueries( + queryAllByImageSrc, + getMultipleError, + getMissingError, +) + +/** + * Get a unique image element matching specified src, or return null if none + * NOTE: decodes NextJS optimized image src by default + */ +const queryByImageSrc = byImageSrc[0] +/** + * Get a 1+ image element matching specified src, or rerror. + * NOTE: decodes NextJS optimized image src by default + */ +const getAllByImageSrc = byImageSrc[1] +/** + * Get exactly 1 image element matching specified src, or rerror. + * NOTE: decodes NextJS optimized image src by default + */ +const getByImageSrc = byImageSrc[2] +/** + * Async find 1+ image element matching specified src, or rerror. + * NOTE: decodes NextJS optimized image src by default + */ +const findAllByImageSrc = byImageSrc[3] +/** + * Async find exactly 1 image element matching specified src, or rerror. + * NOTE: decodes NextJS optimized image src by default + */ +const findByImageSrc = byImageSrc[4] + +export { + queryAllByImageSrc, + queryByImageSrc, + getAllByImageSrc, + getByImageSrc, + findAllByImageSrc, + findByImageSrc, + getOriginalSrc, +} diff --git a/frontends/ol-test-utilities/src/domQueries.ts b/frontends/ol-test-utilities/src/domQueries/byTerm.ts similarity index 54% rename from frontends/ol-test-utilities/src/domQueries.ts rename to frontends/ol-test-utilities/src/domQueries/byTerm.ts index 054ea2bfe4..7aa8382e82 100644 --- a/frontends/ol-test-utilities/src/domQueries.ts +++ b/frontends/ol-test-utilities/src/domQueries/byTerm.ts @@ -1,6 +1,5 @@ import { buildQueries, within } from "@testing-library/react" import type { GetErrorFunction } from "@testing-library/react" -import invariant from "tiny-invariant" /** * Get all
elements whose corresponding
element matches the given term. @@ -42,43 +41,6 @@ const getByTerm = byTerm[2] const findAllByTerm = byTerm[3] const findByTerm = byTerm[4] -/** - * Given an HTMLElement with an aria-describedby attribute, return the elements - * that describe it. - * - * This is particularly useful with `@testing-library`, which makes it easy to - * find form inputs by label, but has no builtin method for finding the - * corresponding descriptions (which you might want when asserting about form - * validation). - */ -const getDescriptionsFor = (el: HTMLElement) => { - const errIdsAttr = el.getAttribute("aria-describedby") - if (errIdsAttr === null) { - throw new Error( - "The specified element does not have an aria-describedby attribute.", - ) - } - const errIds = errIdsAttr.split(" ").filter((id) => id.trim()) - if (errIds.length === 0) { - throw new Error( - "The specified element does not have associated ariia-describedby ids.", - ) - } - const errEls = errIds.map((id) => { - const errEl = document.getElementById(id) - invariant(errEl instanceof HTMLElement, `No element found with id: ${id}`) - return errEl - }) - - return errEls -} - -const getDescriptionFor = (el: HTMLElement) => { - const descriptions = getDescriptionsFor(el) - invariant(descriptions.length === 1, "Expected exactly one description.") - return descriptions[0] -} - export { queryAllByTerm, queryByTerm, @@ -86,5 +48,4 @@ export { getByTerm, findAllByTerm, findByTerm, - getDescriptionFor, } diff --git a/frontends/ol-test-utilities/src/domQueries/forms.ts b/frontends/ol-test-utilities/src/domQueries/forms.ts new file mode 100644 index 0000000000..2848d3fd89 --- /dev/null +++ b/frontends/ol-test-utilities/src/domQueries/forms.ts @@ -0,0 +1,40 @@ +import invariant from "tiny-invariant" + +/** + * Given an HTMLElement with an aria-describedby attribute, return the elements + * that describe it. + * + * This is particularly useful with `@testing-library`, which makes it easy to + * find form inputs by label, but has no builtin method for finding the + * corresponding descriptions (which you might want when asserting about form + * validation). + */ +const getDescriptionsFor = (el: HTMLElement) => { + const errIdsAttr = el.getAttribute("aria-describedby") + if (errIdsAttr === null) { + throw new Error( + "The specified element does not have an aria-describedby attribute.", + ) + } + const errIds = errIdsAttr.split(" ").filter((id) => id.trim()) + if (errIds.length === 0) { + throw new Error( + "The specified element does not have associated ariia-describedby ids.", + ) + } + const errEls = errIds.map((id) => { + const errEl = document.getElementById(id) + invariant(errEl instanceof HTMLElement, `No element found with id: ${id}`) + return errEl + }) + + return errEls +} + +const getDescriptionFor = (el: HTMLElement) => { + const descriptions = getDescriptionsFor(el) + invariant(descriptions.length === 1, "Expected exactly one description.") + return descriptions[0] +} + +export { getDescriptionsFor, getDescriptionFor } diff --git a/frontends/ol-test-utilities/src/index.ts b/frontends/ol-test-utilities/src/index.ts index ae10ad5a02..c2a26f6fb1 100644 --- a/frontends/ol-test-utilities/src/index.ts +++ b/frontends/ol-test-utilities/src/index.ts @@ -1,5 +1,8 @@ export { default as ControlledPromise } from "./ControlledPromise/ControlledPromise" export * from "./factories" -export * from "./domQueries" export * from "./mocks/mocks" export * from "./assertions" + +export * from "./domQueries/byImageSrc" +export * from "./domQueries/byTerm" +export * from "./domQueries/forms" diff --git a/frontends/ol-test-utilities/src/mocks/nextNavigation.ts b/frontends/ol-test-utilities/src/mocks/nextNavigation.ts new file mode 100644 index 0000000000..ca81cf316b --- /dev/null +++ b/frontends/ol-test-utilities/src/mocks/nextNavigation.ts @@ -0,0 +1,29 @@ +/** + * This is a mock for the next/navigation module used with the App Router. + * + * NOTE: next-router-mock is intended to mock the next/router module (used with + * older Pages router.) + * + * See https://github.com/scottrippey/next-router-mock/issues + */ +import * as mocks from "next-router-mock" + +export const nextNavigationMocks = { + ...mocks, + notFound: jest.fn(), + redirect: jest.fn().mockImplementation((url: string) => { + nextNavigationMocks.memoryRouter.setCurrentUrl(url) + }), + usePathname: () => { + const router = nextNavigationMocks.useRouter() + return router.asPath + }, + useSearchParams: () => { + const router = nextNavigationMocks.useRouter() + const path = router.query + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new URLSearchParams(path as any) + }, +} +const mockRouter = nextNavigationMocks.memoryRouter +export { mockRouter } diff --git a/frontends/ol-utilities/jest.config.ts b/frontends/ol-utilities/jest.config.ts index 01eaa01a8e..480e1432c7 100644 --- a/frontends/ol-utilities/jest.config.ts +++ b/frontends/ol-utilities/jest.config.ts @@ -3,11 +3,6 @@ import baseConfig from "../jest.jsdom.config" const config: Config.InitialOptions = { ...baseConfig, - globals: { - APP_SETTINGS: { - MITOL_API_BASE_URL: "https://api.test.learn.mit.edu", - }, - }, } export default config diff --git a/yarn.lock b/yarn.lock index 4c92542f0f..79e59cfb66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5921,7 +5921,7 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^18, @types/react-dom@npm:^18.3.0": +"@types/react-dom@npm:^18.3.0": version: 18.3.0 resolution: "@types/react-dom@npm:18.3.0" dependencies: @@ -5957,13 +5957,13 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^16.8.0 || ^17.0.0 || ^18.0.0, @types/react@npm:^18, @types/react@npm:^18.0.26": - version: 18.3.3 - resolution: "@types/react@npm:18.3.3" +"@types/react@npm:*, @types/react@npm:^16.8.0 || ^17.0.0 || ^18.0.0, @types/react@npm:^18.0.26, @types/react@npm:^18.3.5": + version: 18.3.5 + resolution: "@types/react@npm:18.3.5" dependencies: "@types/prop-types": "npm:*" csstype: "npm:^3.0.2" - checksum: 10/68e203b7f1f91d6cf21f33fc7af9d6d228035a26c83f514981e54aa3da695d0ec6af10c277c6336de1dd76c4adbe9563f3a21f80c4462000f41e5f370b46e96c + checksum: 10/ba0477c5ad4a762157c6262a199af6ccf9e24576877a26a7f516d5a9ba35374a6ac7f8686a10e5e8030513214f02bcb66e8363e43905afb7cd313deaf673de05 languageName: node linkType: hard @@ -14325,8 +14325,8 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.17.7" "@types/node": "npm:^20" - "@types/react": "npm:^18" - "@types/react-dom": "npm:^18" + "@types/react": "npm:^18.3.5" + "@types/react-dom": "npm:^18.3.0" "@types/react-slick": "npm:^0.23.13" "@types/slick-carousel": "npm:^1" api: "workspace:*" @@ -14347,7 +14347,6 @@ __metadata: react: "npm:^18" react-dom: "npm:^18" react-slick: "npm:^0.30.2" - sharp: "npm:^0.33.4" slick-carousel: "npm:^1.8.1" tiny-invariant: "npm:^1.3.3" ts-jest: "npm:^29.2.4" @@ -15825,6 +15824,16 @@ __metadata: languageName: node linkType: hard +"next-router-mock@npm:^0.9.13": + version: 0.9.13 + resolution: "next-router-mock@npm:0.9.13" + peerDependencies: + next: ">=10.0.0" + react: ">=17.0.0" + checksum: 10/582858b4521b987ff7299d97b81b526ba5debdb93b7ada83a595927ca1138082e902ff8dbb7c6cb9db79d65b1a7aa4d86fbbbf24ea73a033d317d964ca3ea95b + languageName: node + linkType: hard + "next@npm:^14.2.7": version: 14.2.7 resolution: "next@npm:14.2.7" @@ -16309,6 +16318,7 @@ __metadata: "@ckeditor/ckeditor5-react": "npm:^7.0.0" "@ckeditor/ckeditor5-theme-lark": "npm:^41.0.0" "@ckeditor/ckeditor5-ui": "npm:^41.0.0" + "@faker-js/faker": "npm:^8.0.0" "@testing-library/react": "npm:16.0.1" axios: "npm:^1.6.3" classnames: "npm:^2.3.2" @@ -16396,6 +16406,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" css-mediaquery: "npm:^0.1.2" dom-accessibility-api: "npm:^0.7.0" + next-router-mock: "npm:^0.9.13" tiny-invariant: "npm:^1.3.1" languageName: unknown linkType: soft @@ -19084,7 +19095,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.33.3, sharp@npm:^0.33.4": +"sharp@npm:^0.33.3": version: 0.33.5 resolution: "sharp@npm:0.33.5" dependencies: