diff --git a/CHANGELOG.md b/CHANGELOG.md index c058ad03..c6f4ea8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix error when a collection is empty - SFR-2077: Validate top 5 language filters for Frontend DRB search results - Add Playwright integration test for cookie validation +- Add Error Boundary for uncaught exceptions ## [0.18.2] diff --git a/mocks/handlers.ts b/mocks/handlers.ts index 52eac9fa..50666b03 100644 --- a/mocks/handlers.ts +++ b/mocks/handlers.ts @@ -1,7 +1,10 @@ import { http, HttpResponse, passthrough } from "msw"; +import { oneCollectionListData } from "~/src/__tests__/fixtures/CollectionFixture"; import { workDetailWithUp } from "~/src/__tests__/fixtures/WorkDetailFixture"; import { API_URL, + COLLECTION_LIST_PATH, + INVALID_COLLECTION_PATH, DOWNLOAD_PATH, FULFILL_PATH, LIMITED_ACCESS_WORK_PATH, @@ -15,6 +18,11 @@ const isAuthenticated = (request) => { const workUrl = new URL(LIMITED_ACCESS_WORK_PATH, API_URL).toString(); const fulfillUrl = new URL(FULFILL_PATH, API_URL).toString(); const downloadUrl = new URL(DOWNLOAD_PATH, API_URL).toString(); +const collectionListUrl = new URL(COLLECTION_LIST_PATH, API_URL).toString(); +const invalidCollectionUrl = new URL( + INVALID_COLLECTION_PATH, + API_URL +).toString(); /** A collection of handlers to be used by default for all tests. */ const handlers = [ @@ -55,6 +63,18 @@ const handlers = [ status: 200, }); }), + + http.get(collectionListUrl, () => { + return HttpResponse.json(oneCollectionListData); + }), + + http.get(invalidCollectionUrl, () => { + return HttpResponse.json({ + status: 500, + title: "Something went wrong", + detail: "An unknown error occurred on the server", + }); + }), ]; export default handlers; diff --git a/mocks/index.ts b/mocks/index.ts index ddd6f52c..b1c55b16 100644 --- a/mocks/index.ts +++ b/mocks/index.ts @@ -1,4 +1,4 @@ -async function initMocks() { +export async function initMocks() { if (typeof window === "undefined") { const { server } = await import("./server"); server.listen({ @@ -12,6 +12,4 @@ async function initMocks() { } } -initMocks(); - export {}; diff --git a/mocks/mockEnv.ts b/mocks/mockEnv.ts index dabb07cb..b9d228a2 100644 --- a/mocks/mockEnv.ts +++ b/mocks/mockEnv.ts @@ -4,3 +4,8 @@ export const FULFILL_PATH = "/fulfill/12345"; export const LIMITED_ACCESS_WORK_PATH = "/work/12345678-1234-1234-1234-1234567890ab"; export const DOWNLOAD_PATH = "/test-download-pdf"; +export const HOME_PATH = "/"; +export const COLLECTION_LIST_PATH = "/collection/list"; +export const COLLECTION_PATH = + "/collection/978ea0e0-8ecc-4de2-bfe8-032fea641d8e?page=1"; +export const INVALID_COLLECTION_PATH = "/collection/invalid-collection"; diff --git a/package.json b/package.json index 97dc29ab..e0f15b47 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,6 @@ "prettier": "^2.2.1", "ts-node": "^10.9.1" }, - "resolutions": { - "@types/react": "^17.0.2" - }, "msw": { "workerDirectory": "public" } diff --git a/playwright/integration/landing.spec.ts b/playwright/integration/landing.spec.ts new file mode 100644 index 00000000..fb77f97c --- /dev/null +++ b/playwright/integration/landing.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "../support/test-utils"; +import { + INVALID_COLLECTION_PATH, + HOME_PATH, + COLLECTION_PATH, +} from "~/mocks/mockEnv"; +import { server } from "~/mocks/server"; + +test.afterEach(() => server.resetHandlers()); +test.afterAll(() => server.close()); + +test("View landing page with collection", async ({ page, port }) => { + await page.goto(`http://localhost:${port}${HOME_PATH}`); + const collectionHeading = page.getByRole("heading", { + name: "Recently Added Collections", + level: 2, + }); + expect(collectionHeading).toBeVisible(); + const collectionLink = await page + .getByRole("link", { + name: /Baseball: A Collection by Mike Benowitz/, + }) + .getAttribute("href"); + expect(collectionLink).toContain(COLLECTION_PATH); +}); + +test("Shows error boundary for invalid collection", async ({ page, port }) => { + await page.goto(`http://localhost:${port}${INVALID_COLLECTION_PATH}`); + + const alert = page.getByRole("alert"); + const errorText = alert.getByText("An error 500 occurred on server"); + await expect(errorText).toBeVisible(); +}); diff --git a/playwright/support/test-utils.ts b/playwright/support/test-utils.ts index 626fd905..9cad8a9a 100644 --- a/playwright/support/test-utils.ts +++ b/playwright/support/test-utils.ts @@ -5,10 +5,12 @@ import { parse } from "url"; import { AddressInfo } from "net"; import next from "next"; import path from "path"; +import { http } from "msw"; const test = base.extend<{ setCookie(expires?: number): Promise; port: string; + http: typeof http; }>({ setCookie: [ async ({ context }, use, _expires) => { @@ -57,6 +59,7 @@ const test = base.extend<{ }, { auto: true }, ], + http, }); export { test, expect }; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..eddd3a95 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import React, { ErrorInfo } from "react"; +import Error from "../pages/_error"; + +interface ErrorBoundaryProps { + children?: React.ReactNode; +} + +interface ErrorBoundaryState { + error?: Error; + info?: React.ErrorInfo; +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props) { + super(props); + + this.state = { error: undefined, info: undefined }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // TODO: add logging to New Relic + this.setState({ + error, + info: errorInfo, + }); + } + + render() { + const { error } = this.state; + + if (error) { + return ; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5c2bd73d..b580b9d1 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -13,9 +13,11 @@ import { trackPageview } from "../lib/adobe/Analytics"; import { pageNames } from "../constants/analytics"; import { getQueryDecodedString } from "../util/SearchQueryUtils"; import NewRelicSnippet from "../lib/newrelic/NewRelic"; +import ErrorBoundary from "../components/ErrorBoundary"; if (process.env.APP_ENV === "testing") { - require("mocks"); + const { initMocks } = await import("mocks"); + await initMocks(); } /** @@ -90,10 +92,12 @@ const MyApp = ({ Component, pageProps }: AppProps) => { - - - - + + + + + + ); }; diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index f18e60ed..3b0bea1a 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -6,16 +6,18 @@ import Link from "../components/Link/Link"; const Error = ({ statusCode }) => { return ( -

- {statusCode - ? `An error ${statusCode} occurred on server` - : "An error occurred on client"} -

-

- Search  - Digital Research Books Beta - {"."} -

+
+

+ {statusCode + ? `An error ${statusCode} occurred on server` + : "An error occurred on client"} +

+

+ Search  + Digital Research Books Beta + {"."} +

+
); }; diff --git a/tsconfig.json b/tsconfig.json index e402cd41..093e3621 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,11 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], "moduleResolution": "node", "baseUrl": ".", "paths": { - "~/*": [ - "*" - ] + "~/*": ["*"] }, "allowJs": true, "skipLibCheck": true, @@ -46,9 +40,5 @@ "src/components/RequestDigital/RequestDigital.jsx", "src/__tests__/index.test.tsx" ], - "exclude": [ - "node_modules", - "jest.config.ts", - "setupTests.js" - ] + "exclude": ["node_modules", "jest.config.ts", "setupTests.js"] }