From f2e6d1d1ac64b4fc3d3d295c216aee12923793b7 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Fri, 23 Aug 2024 15:11:14 -0400 Subject: [PATCH 1/2] implement error boundary --- CHANGELOG.md | 1 + mocks/handlers.ts | 16 ++++++++++ mocks/mockEnv.ts | 4 +++ playwright/integration/landing.spec.ts | 40 ++++++++++++++++++++++++ playwright/support/test-utils.ts | 3 ++ src/components/ErrorBoundary.tsx | 42 ++++++++++++++++++++++++++ src/pages/_app.tsx | 9 ++++-- src/pages/_error.tsx | 22 ++++++++------ 8 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 playwright/integration/landing.spec.ts create mode 100644 src/components/ErrorBoundary.tsx 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..b941ccd8 100644 --- a/mocks/handlers.ts +++ b/mocks/handlers.ts @@ -1,7 +1,13 @@ import { http, HttpResponse, passthrough } from "msw"; +import { + collectionItem, + oneCollectionListData, +} from "~/src/__tests__/fixtures/CollectionFixture"; import { workDetailWithUp } from "~/src/__tests__/fixtures/WorkDetailFixture"; import { API_URL, + COLLECTION_LIST_PATH, + COLLECTION_PATH, DOWNLOAD_PATH, FULFILL_PATH, LIMITED_ACCESS_WORK_PATH, @@ -15,6 +21,8 @@ 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 collectionUrl = new URL(COLLECTION_PATH, API_URL).toString(); /** A collection of handlers to be used by default for all tests. */ const handlers = [ @@ -55,6 +63,14 @@ const handlers = [ status: 200, }); }), + + http.get(collectionListUrl, () => { + return HttpResponse.json(oneCollectionListData); + }), + + http.get(collectionUrl, () => { + return HttpResponse.json(collectionItem); + }), ]; export default handlers; diff --git a/mocks/mockEnv.ts b/mocks/mockEnv.ts index dabb07cb..a9a147fa 100644 --- a/mocks/mockEnv.ts +++ b/mocks/mockEnv.ts @@ -4,3 +4,7 @@ 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"; diff --git a/playwright/integration/landing.spec.ts b/playwright/integration/landing.spec.ts new file mode 100644 index 00000000..152a136c --- /dev/null +++ b/playwright/integration/landing.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "../support/test-utils"; +import { API_URL, COLLECTION_PATH, HOME_PATH } from "~/mocks/mockEnv"; +import { server } from "~/mocks/server"; +import { HttpResponse } from "msw"; + +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 = page.getByRole("link", { + name: /Baseball: A Collection by Mike Benowitz/, + }); + await expect(collectionLink).toHaveAttribute("href", COLLECTION_PATH); +}); + +test("Shows error boundary on error", async ({ page, port, http }) => { + await page.goto(`http://localhost:${port}${HOME_PATH}`); + server.use( + http.get(new RegExp(API_URL), async () => { + return HttpResponse.json( + { + status: 500, + title: "Something went wrong", + detail: "An unknown error occurred on the server", + }, + { status: 500 } + ); + }) + ); + + 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..26c244e2 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,42 @@ +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 +> { + 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..8abc3456 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -13,6 +13,7 @@ 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"); @@ -91,9 +92,11 @@ 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 + {"."} +

+
); }; From e01c9164c4e2bbdeb0583c2927d57bf2c4b2d91c Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Tue, 27 Aug 2024 15:36:08 -0400 Subject: [PATCH 2/2] fix integration tests --- mocks/handlers.ts | 20 +++++++++------- mocks/index.ts | 4 +--- mocks/mockEnv.ts | 3 ++- package.json | 3 --- playwright/integration/landing.spec.ts | 33 ++++++++++---------------- src/components/ErrorBoundary.tsx | 8 +++++-- src/pages/_app.tsx | 5 ++-- tsconfig.json | 18 ++++---------- 8 files changed, 41 insertions(+), 53 deletions(-) diff --git a/mocks/handlers.ts b/mocks/handlers.ts index b941ccd8..50666b03 100644 --- a/mocks/handlers.ts +++ b/mocks/handlers.ts @@ -1,13 +1,10 @@ import { http, HttpResponse, passthrough } from "msw"; -import { - collectionItem, - oneCollectionListData, -} from "~/src/__tests__/fixtures/CollectionFixture"; +import { oneCollectionListData } from "~/src/__tests__/fixtures/CollectionFixture"; import { workDetailWithUp } from "~/src/__tests__/fixtures/WorkDetailFixture"; import { API_URL, COLLECTION_LIST_PATH, - COLLECTION_PATH, + INVALID_COLLECTION_PATH, DOWNLOAD_PATH, FULFILL_PATH, LIMITED_ACCESS_WORK_PATH, @@ -22,7 +19,10 @@ 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 collectionUrl = new URL(COLLECTION_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 = [ @@ -68,8 +68,12 @@ const handlers = [ return HttpResponse.json(oneCollectionListData); }), - http.get(collectionUrl, () => { - return HttpResponse.json(collectionItem); + http.get(invalidCollectionUrl, () => { + return HttpResponse.json({ + status: 500, + title: "Something went wrong", + detail: "An unknown error occurred on the server", + }); }), ]; 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 a9a147fa..b9d228a2 100644 --- a/mocks/mockEnv.ts +++ b/mocks/mockEnv.ts @@ -7,4 +7,5 @@ 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"; + "/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 index 152a136c..fb77f97c 100644 --- a/playwright/integration/landing.spec.ts +++ b/playwright/integration/landing.spec.ts @@ -1,7 +1,10 @@ import { test, expect } from "../support/test-utils"; -import { API_URL, COLLECTION_PATH, HOME_PATH } from "~/mocks/mockEnv"; +import { + INVALID_COLLECTION_PATH, + HOME_PATH, + COLLECTION_PATH, +} from "~/mocks/mockEnv"; import { server } from "~/mocks/server"; -import { HttpResponse } from "msw"; test.afterEach(() => server.resetHandlers()); test.afterAll(() => server.close()); @@ -13,26 +16,16 @@ test("View landing page with collection", async ({ page, port }) => { level: 2, }); expect(collectionHeading).toBeVisible(); - const collectionLink = page.getByRole("link", { - name: /Baseball: A Collection by Mike Benowitz/, - }); - await expect(collectionLink).toHaveAttribute("href", COLLECTION_PATH); + const collectionLink = await page + .getByRole("link", { + name: /Baseball: A Collection by Mike Benowitz/, + }) + .getAttribute("href"); + expect(collectionLink).toContain(COLLECTION_PATH); }); -test("Shows error boundary on error", async ({ page, port, http }) => { - await page.goto(`http://localhost:${port}${HOME_PATH}`); - server.use( - http.get(new RegExp(API_URL), async () => { - return HttpResponse.json( - { - status: 500, - title: "Something went wrong", - detail: "An unknown error occurred on the server", - }, - { status: 500 } - ); - }) - ); +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"); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 26c244e2..eddd3a95 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -14,7 +14,11 @@ class ErrorBoundary extends React.Component< ErrorBoundaryProps, ErrorBoundaryState > { - state = { error: undefined, info: undefined }; + constructor(props) { + super(props); + + this.state = { error: undefined, info: undefined }; + } static getDerivedStateFromError(error: Error) { return { error }; @@ -32,7 +36,7 @@ class ErrorBoundary extends React.Component< const { error } = this.state; if (error) { - return ; + return ; } return this.props.children; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8abc3456..b580b9d1 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -16,7 +16,8 @@ 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(); } /** @@ -91,8 +92,8 @@ const MyApp = ({ Component, pageProps }: AppProps) => { - + 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"] }