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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,26 @@ the hooks in `src/lib`.
### Route-level boundary (`src/app/error.tsx`)

Catches exceptions thrown inside individual route segments. Renders inside the
root layout so the Header, Footer, and ToastProvider remain visible. Shows the
error message and a "Try again" button that calls Next.js's `reset()`.
root layout so the Header, Footer, and ToastProvider remain visible.

Key features:

- **Accessible error presentation:** Error message wrapped in `role="alert"` so
screen readers announce it immediately.
- **Recovery action:** "Try again" button (using the shared `Button` component)
wired to Next.js's `reset()` callback allows users to retry without a full page
reload — essential for transient failures like flaky fetches.
- **Production safety:** Only `error.message` is rendered; stack traces never
leak into the DOM.
- **Debug support:** `error.digest` (if present) is logged to the console for
debugging but not prominently displayed to users.
- **Keyboard accessible:** Button includes `focus-visible` outline and is fully
operable via keyboard.
- **Dark mode compatible:** All styling supports both light and dark themes.

Behaviour is covered by `src/app/error.test.tsx`, which asserts the alert region
renders, the reset callback is invoked on click, no stack traces appear, and edge
cases like empty messages are handled gracefully.

### Global boundary (`src/app/global-error.tsx`)

Expand Down Expand Up @@ -234,6 +252,10 @@ The following frontend routes are defined under `src/app/`:

The home page (`src/app/page.tsx`) renders the primary navigation entry points (Manage services, View stats, Record usage, Agents, Docs) and the external Stellar link inside a `<nav aria-label="Quick links">` landmark with a semantic `<ul>` / `<li>` list structure. This improves discoverability for screen-reader users.

## 404 page recovery links

The 404 page (`src/app/not-found.tsx`) renders a `<nav aria-label="Helpful links">` landmark below the error message with quick-return links to four primary surfaces (Home, Services, Stats, Docs). This gives users a semantic, keyboard-accessible path back into the app without relying on the browser back button. The navigation uses a `<ul>` / `<li>` list structure and all links include `focus-visible` outlines for keyboard accessibility.

## Security headers

A baseline security header set (CSP, `X-Frame-Options: DENY`, `Referrer-Policy`, `X-Content-Type-Options`, `Permissions-Policy`, HSTS) is wired up in `next.config.ts` via `src/lib/securityHeaders.ts`. The CSP `connect-src` directive tracks `NEXT_PUBLIC_AGENTPAY_API_BASE` automatically; `<a href>` links to external sites (`https://stellar.org`, etc.) remain navigable.
Expand Down
306 changes: 306 additions & 0 deletions src/app/error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { render, screen, fireEvent } from "@testing-library/react";
import ErrorBoundary from "./error";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeError(
message: string,
digest?: string
): Error & { digest?: string } {
const err = new Error(message) as Error & { digest?: string };
if (digest !== undefined) err.digest = digest;
return err;
}

// Silence console.error noise produced by the useEffect log inside the
// component so Jest output stays clean.
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

describe("ErrorBoundary — rendering", () => {
it("renders the heading", () => {
render(<ErrorBoundary error={makeError("boom")} reset={() => {}} />);
expect(
screen.getByRole("heading", { name: /something went wrong/i })
).toBeInTheDocument();
});

it("renders the error message when one is provided", () => {
render(
<ErrorBoundary error={makeError("Network timeout")} reset={() => {}} />
);
expect(screen.getByText("Network timeout")).toBeInTheDocument();
});

it("renders fallback copy when error.message is empty", () => {
render(<ErrorBoundary error={makeError("")} reset={() => {}} />);
expect(
screen.getByText(/an unexpected error occurred/i)
).toBeInTheDocument();
});

it("renders a Try again button", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
expect(
screen.getByRole("button", { name: /try again/i })
).toBeInTheDocument();
});

it("wraps error message in a role=alert region", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const alert = screen.getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveTextContent("oops");
});

it("renders the main landmark with id=main-content", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const main = screen.getByRole("main");
expect(main).toHaveAttribute("id", "main-content");
expect(main).toHaveAttribute("tabIndex", "-1");
});
});

// ---------------------------------------------------------------------------
// Button component integration
// ---------------------------------------------------------------------------

describe("ErrorBoundary — Button component", () => {
it("uses the Button component for Try again action", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const button = screen.getByRole("button", { name: /try again/i });
expect(button).toHaveAttribute("type", "button");
// Button component applies specific classes
expect(button.className).toMatch(/rounded-full/);
expect(button.className).toMatch(/px-5/);
expect(button.className).toMatch(/py-2/);
});

it("Button has primary variant styling", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const button = screen.getByRole("button", { name: /try again/i });
expect(button.className).toMatch(/bg-black/);
expect(button.className).toMatch(/text-white/);
});

it("Button has focus-visible outline", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const button = screen.getByRole("button", { name: /try again/i });
expect(button.className).toMatch(/focus-visible:outline/);
});
});

// ---------------------------------------------------------------------------
// reset callback
// ---------------------------------------------------------------------------

describe("ErrorBoundary — reset interaction", () => {
it("calls reset once when Try again is clicked", () => {
const reset = jest.fn();
render(<ErrorBoundary error={makeError("oops")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
expect(reset).toHaveBeenCalledTimes(1);
});

it("calls reset again on each subsequent click", () => {
const reset = jest.fn();
render(<ErrorBoundary error={makeError("oops")} reset={reset} />);
const btn = screen.getByRole("button", { name: /try again/i });
fireEvent.click(btn);
fireEvent.click(btn);
fireEvent.click(btn);
expect(reset).toHaveBeenCalledTimes(3);
});

it("does not call reset on render, only on click", () => {
const reset = jest.fn();
render(<ErrorBoundary error={makeError("oops")} reset={reset} />);
expect(reset).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// Production safety — no stack traces in the DOM
// ---------------------------------------------------------------------------

describe("ErrorBoundary — production safety", () => {
it("does not render the error stack trace", () => {
const err = makeError("bad thing");
err.stack = "Error: bad thing\n at Component (file.tsx:10:5)";
const { container } = render(
<ErrorBoundary error={err} reset={() => {}} />
);
// The raw stack string must never appear anywhere in the rendered output.
expect(container.textContent).not.toContain(err.stack);
expect(container.textContent).not.toMatch(/at Component/);
});

it("does not render any stack-like content even when error has a long stack", () => {
const err = makeError("crash");
err.stack = [
"Error: crash",
" at ErrorBoundary (error.tsx:10:5)",
" at renderWithHooks (react-dom.development.js:14985:18)",
" at mountIndeterminateComponent (react-dom.development.js:17811:13)",
].join("\n");
const { container } = render(
<ErrorBoundary error={err} reset={() => {}} />
);
expect(container.textContent).not.toMatch(/renderWithHooks/);
expect(container.textContent).not.toMatch(/mountIndeterminateComponent/);
});

it("only renders error.message, never error.stack", () => {
const err = makeError("User-friendly message");
err.stack = "Error: User-friendly message\n at dangerous stack trace";
const { container } = render(
<ErrorBoundary error={err} reset={() => {}} />
);
expect(container.textContent).toContain("User-friendly message");
expect(container.textContent).not.toContain("dangerous stack trace");
});
});

// ---------------------------------------------------------------------------
// console.error logging
// ---------------------------------------------------------------------------

describe("ErrorBoundary — error logging", () => {
it("logs the error via console.error on mount", () => {
const err = makeError("logged error");
render(<ErrorBoundary error={err} reset={() => {}} />);
expect(console.error).toHaveBeenCalledWith(
"App error boundary caught:",
err
);
});

it("logs the error digest when present", () => {
const err = makeError("crash", "abc-123");
render(<ErrorBoundary error={err} reset={() => {}} />);
expect(console.error).toHaveBeenCalledWith("Error digest:", "abc-123");
});

it("does not log digest when digest is absent", () => {
const err = makeError("crash");
render(<ErrorBoundary error={err} reset={() => {}} />);
expect(console.error).not.toHaveBeenCalledWith(
expect.stringMatching(/digest/),
expect.anything()
);
});

it("does not log digest when digest is undefined", () => {
const err = makeError("crash", undefined);
render(<ErrorBoundary error={err} reset={() => {}} />);
const calls = (console.error as jest.Mock).mock.calls;
const digestCalls = calls.filter((call) =>
call.some((arg: unknown) => typeof arg === "string" && arg.includes("digest"))
);
expect(digestCalls).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// Dark mode styling
// ---------------------------------------------------------------------------

describe("ErrorBoundary — dark mode", () => {
it("applies dark mode classes to the error message", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const alert = screen.getByRole("alert");
expect(alert.className).toMatch(/dark:text-zinc-400/);
});

it("main landmark supports dark mode focus styles", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const main = screen.getByRole("main");
expect(main.className).toMatch(/focus:outline-none/);
});
});

// ---------------------------------------------------------------------------
// Accessibility
// ---------------------------------------------------------------------------

describe("ErrorBoundary — accessibility", () => {
it("error message is announced via role=alert", () => {
render(<ErrorBoundary error={makeError("Critical error")} reset={() => {}} />);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Critical error");
});

it("Try again button is keyboard operable", () => {
const reset = jest.fn();
render(<ErrorBoundary error={makeError("oops")} reset={reset} />);
const button = screen.getByRole("button", { name: /try again/i });

// Simulate keyboard activation
button.focus();
fireEvent.keyDown(button, { key: "Enter", code: "Enter" });
fireEvent.click(button);

expect(reset).toHaveBeenCalled();
});

it("main landmark can receive focus for skip links", () => {
render(<ErrorBoundary error={makeError("oops")} reset={() => {}} />);
const main = screen.getByRole("main");
expect(main).toHaveAttribute("tabIndex", "-1");
expect(main).toHaveAttribute("id", "main-content");
});
});

// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------

describe("ErrorBoundary — edge cases", () => {
it("handles error with undefined message", () => {
const err = new Error() as Error & { digest?: string };
render(<ErrorBoundary error={err} reset={() => {}} />);
expect(
screen.getByText(/an unexpected error occurred/i)
).toBeInTheDocument();
});

it("handles error with null-like properties", () => {
const err = {
message: "",
name: "Error",
} as Error & { digest?: string };
render(<ErrorBoundary error={err} reset={() => {}} />);
expect(screen.getByRole("alert")).toHaveTextContent(
/an unexpected error occurred/i
);
});

it("handles very long error messages without breaking layout", () => {
const longMessage = "A".repeat(500);
render(<ErrorBoundary error={makeError(longMessage)} reset={() => {}} />);
expect(screen.getByRole("alert")).toHaveTextContent(longMessage);
});

it("renders correctly when error message contains HTML-like characters", () => {
render(
<ErrorBoundary
error={makeError("<script>alert('xss')</script>")}
reset={() => {}}
/>
);
const alert = screen.getByRole("alert");
// Text content should be escaped, not parsed as HTML
expect(alert.textContent).toBe("<script>alert('xss')</script>");
});
});
18 changes: 9 additions & 9 deletions src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect } from "react";
import { Button } from "@/components/Button";

export default function ErrorBoundary({
error,
Expand All @@ -11,6 +12,9 @@ export default function ErrorBoundary({
}) {
useEffect(() => {
console.error("App error boundary caught:", error);
if (error.digest) {
console.error("Error digest:", error.digest);
}
}, [error]);

return (
Expand All @@ -20,16 +24,12 @@ export default function ErrorBoundary({
className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center gap-4 p-8 text-center focus:outline-none"
>
<h1 className="text-2xl font-semibold">Something went wrong.</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{error.message || "Unexpected error."}
</p>
<button
type="button"
onClick={reset}
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
<div role="alert" className="text-sm text-zinc-600 dark:text-zinc-400">
{error.message || "An unexpected error occurred."}
</div>
<Button type="button" onClick={reset}>
Try again
</button>
</Button>
</main>
);
}
Loading
Loading