Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 165 additions & 17 deletions __tests__/components/error/Error.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";

import ErrorComponent from "@/components/error/Error";

const setTitleMock = jest.fn();
const copyToClipboardMock = jest.fn();
let mockSearchParams: URLSearchParams;

jest.mock("@/contexts/TitleContext", () => ({
__esModule: true,
Expand All @@ -13,18 +15,7 @@ jest.mock("@/contexts/TitleContext", () => ({

jest.mock("next/navigation", () => ({
__esModule: true,
useRouter: () => ({
back: jest.fn(),
}),
}));

jest.mock("next/link", () => ({
__esModule: true,
default: ({ children, href, ...props }: any) => (
<a href={typeof href === "string" ? href : href?.toString()} {...props}>
{children}
</a>
),
useSearchParams: () => mockSearchParams,
}));

jest.mock("next/image", () => ({
Expand All @@ -35,9 +26,31 @@ jest.mock("next/image", () => ({
},
}));

jest.mock("react-use", () => ({
__esModule: true,
useCopyToClipboard: () => [null, copyToClipboardMock],
}));

jest.mock("framer-motion", () => ({
__esModule: true,
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}));

describe("ErrorComponent", () => {
beforeEach(() => {
jest.clearAllTimers();
jest.useFakeTimers();
setTitleMock.mockClear();
copyToClipboardMock.mockClear();
mockSearchParams = new URLSearchParams();
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

it("sets the error page title and shows contact email", () => {
Expand All @@ -49,11 +62,146 @@ describe("ErrorComponent", () => {
expect(supportLink).toHaveAttribute("href", "mailto:support@6529.io");
});

it("renders a styled link back to the home page", () => {
it("reveals a provided stack trace when toggled", () => {
render(<ErrorComponent stackTrace="Error: stack" />);

const toggleButton = screen.getByRole("button", {
name: /show stacktrace/i,
});
fireEvent.click(toggleButton);

expect(screen.getByText("Error: stack")).toBeInTheDocument();
});

it("uses the stack trace from the URL when no prop is provided", () => {
mockSearchParams = new URLSearchParams("stack=FromQuery");

render(<ErrorComponent />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));

expect(screen.getByText("FromQuery")).toBeInTheDocument();
});

it("does not show stack trace section when no stack trace or digest is provided", () => {
render(<ErrorComponent />);

expect(
screen.queryByRole("button", { name: /show stacktrace/i })
).not.toBeInTheDocument();
});

it("shows stack trace section when digest is provided even without stack trace", () => {
render(<ErrorComponent digest="123456" />);

expect(
screen.getByRole("button", { name: /show stacktrace/i })
).toBeInTheDocument();
});

it("toggles stack trace visibility", () => {
render(<ErrorComponent stackTrace="Error: test" />);

const toggleButton = screen.getByRole("button", {
name: /show stacktrace/i,
});
expect(screen.queryByText("Error: test")).not.toBeInTheDocument();

fireEvent.click(toggleButton);
expect(screen.getByText("Error: test")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /hide stacktrace/i })
).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /hide stacktrace/i }));
expect(screen.queryByText("Error: test")).not.toBeInTheDocument();
});

it("shows copy button when stack trace is expanded", () => {
render(<ErrorComponent stackTrace="Error: test" />);

const toggleButton = screen.getByRole("button", {
name: /show stacktrace/i,
});
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();

fireEvent.click(toggleButton);
expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument();
});

it("copies stack trace to clipboard when copy button is clicked", () => {
render(<ErrorComponent stackTrace="Error: test" />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));
fireEvent.click(screen.getByRole("button", { name: /copy/i }));

expect(copyToClipboardMock).toHaveBeenCalledWith("Error: test");
});

it("includes digest in copied text when digest is provided", () => {
render(<ErrorComponent stackTrace="Error: test" digest="123456" />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));
fireEvent.click(screen.getByRole("button", { name: /copy/i }));

expect(copyToClipboardMock).toHaveBeenCalledWith("123456\n\nError: test");
});

it("shows 'Copied' and disables button after copy is clicked", () => {
render(<ErrorComponent stackTrace="Error: test" />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));
const copyButton = screen.getByRole("button", { name: /copy/i });

fireEvent.click(copyButton);

expect(screen.getByRole("button", { name: /copied/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /copied/i })).toBeDisabled();
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
});

it("resets copy button state after 2 seconds", async () => {
render(<ErrorComponent stackTrace="Error: test" />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));
const copyButton = screen.getByRole("button", { name: /copy/i });

fireEvent.click(copyButton);
expect(screen.getByRole("button", { name: /copied/i })).toBeInTheDocument();

jest.advanceTimersByTime(2000);

await waitFor(() => {
expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /copied/i })).not.toBeInTheDocument();
});
});

it("displays digest in stack trace when provided", () => {
render(<ErrorComponent stackTrace="Error: test" digest="123456" />);

fireEvent.click(screen.getByRole("button", { name: /show stacktrace/i }));

expect(screen.getByText("Digest: 123456")).toBeInTheDocument();
expect(screen.getByText("Error: test")).toBeInTheDocument();
});

it("shows Try Again button when onReset is provided", () => {
const resetMock = jest.fn();
render(<ErrorComponent onReset={resetMock} />);

const tryAgainButton = screen.getByRole("button", { name: /try again/i });
expect(tryAgainButton).toBeInTheDocument();

fireEvent.click(tryAgainButton);
expect(resetMock).toHaveBeenCalledTimes(1);
});

it("does not show Try Again button when onReset is not provided", () => {
render(<ErrorComponent />);

const homeLink = screen.getByRole("link", { name: "6529 HOME" });
expect(homeLink).toHaveAttribute("href", "/");
expect(homeLink).toHaveClass("tw-mt-5", "tw-text-lg", "tw-font-semibold");
expect(
screen.queryByRole("button", { name: /try again/i })
).not.toBeInTheDocument();
});
});
14 changes: 6 additions & 8 deletions __tests__/components/not-found/NotFound.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,13 @@ describe("NotFound", () => {
it("sets the title and renders default PAGE not found when no label", () => {
render(<NotFound />);

expect(setTitleMock).toHaveBeenCalledWith("404 - PAGE NOT FOUND");
expect(setTitleMock).toHaveBeenCalledWith("404 | PAGE NOT FOUND");

// Heading content
expect(
screen.getByRole("heading", { level: 3, name: "404 | PAGE NOT FOUND" })
).toBeInTheDocument();

// Home link
const homeLink = screen.getByRole("link", { name: "6529 HOME" });
expect(homeLink).toHaveAttribute("href", "/");
expect(homeLink).toHaveClass("tw-mt-5", "tw-text-lg", "tw-font-semibold");

// Images render
expect(screen.getByAltText("SummerGlasses")).toBeInTheDocument();
expect(screen.getByAltText("sgt_flushed")).toBeInTheDocument();
Expand All @@ -62,9 +57,12 @@ describe("NotFound", () => {
it("uppercases the provided label in title and heading", () => {
render(<NotFound label="mEmE" />);

expect(setTitleMock).toHaveBeenCalledWith("404 - MEME NOT FOUND");
expect(setTitleMock).toHaveBeenCalledWith("404 | MEME NOT FOUND");
expect(
screen.getByRole("heading", { level: 3, name: "404 | MEME NOT FOUND" })
screen.getByRole("heading", {
level: 3,
name: "404 | MEME NOT FOUND",
})
).toBeInTheDocument();
});
});
20 changes: 1 addition & 19 deletions __tests__/moreStaticPages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import NotFoundPage from "@/app/not-found";
import { AuthContext } from "@/components/auth/Auth";
import MemeLabCollection from "@/components/memelab/MemeLabCollection";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import React, { useMemo } from "react";

Expand Down Expand Up @@ -88,11 +87,7 @@ describe("additional static pages", () => {
cleanup();
jest.clearAllMocks();
});
it("renders 404 not-found page with proper content and navigation", async () => {
const routerMock = { back: jest.fn(), push: jest.fn() };
(useRouter as jest.Mock).mockReturnValue(routerMock);
const user = userEvent.setup();

it("renders 404 not-found page with proper content", async () => {
render(
<TestProvider>
<NotFoundPage />
Expand All @@ -102,19 +97,6 @@ describe("additional static pages", () => {
// Check for main error message
expect(screen.getByText(/404 \| PAGE NOT FOUND/i)).toBeInTheDocument();

// Check for navigation link
const homeLink = screen.getByRole("link", { name: /6529 HOME/i });
expect(homeLink).toBeInTheDocument();
expect(homeLink).toHaveAttribute("href", "/");

const backButton = screen.getByRole("button", {
name: /back to previous page/i,
});
expect(backButton).toBeInTheDocument();

await user.click(backButton);
expect(routerMock.back).toHaveBeenCalledTimes(1);

// Check for visual elements
expect(screen.getByAltText("SummerGlasses")).toBeInTheDocument();
expect(screen.getByAltText("sgt_flushed")).toBeInTheDocument();
Expand Down
2 changes: 1 addition & 1 deletion app/[user]/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function UserNotFoundPage() {
return (
<main className={styles.main}>
<Suspense fallback={null}>
<NotFound label="USER" />
<NotFound label="USER OR PAGE" />
</Suspense>
</main>
);
Expand Down
13 changes: 0 additions & 13 deletions app/error-page.tsx

This file was deleted.

24 changes: 24 additions & 0 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import ErrorComponent from "@/components/error/Error";
import styles from "@/styles/Home.module.scss";
import { extractErrorDetails } from "@/utils/error-extractor";

type ErrorProps = {
readonly error: Error & { digest?: string };
readonly reset: () => void;
};

export default function ErrorPage({ error, reset }: ErrorProps) {
const errorDetails = extractErrorDetails(error, "ROUTE_ERROR");

return (
<main className={styles.main}>
<ErrorComponent
stackTrace={errorDetails}
digest={error.digest}
onReset={reset}
/>
</main>
);
}
14 changes: 10 additions & 4 deletions app/error/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Suspense } from "react";
import styles from "@/styles/Home.module.scss";
import ErrorComponent from "@/components/error/Error";
import styles from "@/styles/Home.module.scss";
import { Suspense } from "react";

type ErrorPageProps = {
readonly searchParams?: Promise<{ readonly stack?: string }>;
};

export default async function ErrorPage({ searchParams }: ErrorPageProps) {
const stackTraceParam = (await searchParams)?.stack ?? null;

export default function ErrorPage() {
return (
<main className={styles.main}>
<Suspense fallback={null}>
<ErrorComponent />
<ErrorComponent stackTrace={stackTraceParam} />
</Suspense>
</main>
);
Expand Down
28 changes: 28 additions & 0 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import ErrorComponent from "@/components/error/Error";
import styles from "@/styles/Home.module.scss";
import { extractErrorDetails } from "@/utils/error-extractor";

type GlobalErrorProps = {
readonly error: Error & { digest?: string };
readonly reset: () => void;
};

export default function GlobalError({ error, reset }: GlobalErrorProps) {
const errorDetails = extractErrorDetails(error, "GLOBAL_ERROR");

return (
<html lang="en">
<body>
<main className={styles.main}>
<ErrorComponent
stackTrace={errorDetails}
digest={error.digest}
onReset={reset}
/>
</main>
</body>
</html>
);
}
Loading
Loading