diff --git a/src/App.tsx b/src/App.tsx
index f675c83..256243f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ const SetupPage = lazy(() => import("./pages/SetupPage"));
const BatchMultiCall = lazy(() => import("./pages/BatchMultiCall"));
const SubInvocationPage = lazy(() => import("./pages/SubInvocationPage"));
const RateLimitDashboard = lazy(() => import("./pages/RateLimitDashboard"));
+const NotFound = lazy(() => import("./pages/NotFound"));
function Fallback() {
return
Loading…
;
@@ -45,6 +46,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
new file mode 100644
index 0000000..d4988e2
--- /dev/null
+++ b/src/pages/NotFound.tsx
@@ -0,0 +1,114 @@
+import { Link, useLocation } from "react-router-dom";
+
+const NAV_LINKS: { label: string; to: string; description: string }[] = [
+ { label: "Home", to: "/", description: "Recent contract events" },
+ { label: "Search", to: "/search", description: "Find contracts, wallets & events" },
+ { label: "Graph", to: "/graph", description: "Contract relationship graph" },
+ { label: "XDR Inspector", to: "/xdr", description: "Decode XDR envelopes" },
+ { label: "Sandbox", to: "/sandbox", description: "Prototype against live contracts" },
+];
+
+export default function NotFound() {
+ const { pathname } = useLocation();
+
+ return (
+
+ {/* Status code */}
+
+ 404
+
+
+ {/* Heading */}
+
Page not found
+
+ {/* Path that was requested */}
+
+
+ {pathname}
+ {" "}
+ doesn't match any known route.
+
+
+ {/* Quick-nav back into the app */}
+
+
+ {/* Go back button */}
+
+
+ );
+}
diff --git a/test/NotFound.test.tsx b/test/NotFound.test.tsx
new file mode 100644
index 0000000..b0559c7
--- /dev/null
+++ b/test/NotFound.test.tsx
@@ -0,0 +1,116 @@
+/**
+ * Issue #19 — Add a catch-all 404 route
+ *
+ * Tests:
+ * 1. Navigating to an unknown path renders the NotFound component.
+ * 2. The NotFound page displays the requested path.
+ * 3. The NotFound page provides at least one working link back into the app.
+ * 4. Known routes (e.g. "/search") are NOT swallowed by the catch-all.
+ */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import NotFound from "../src/pages/NotFound";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeQC() {
+ return new QueryClient({ defaultOptions: { queries: { retry: false } } });
+}
+
+/**
+ * Render the full route tree (mirroring App.tsx) at a given initial path.
+ * Keeps the test scope narrow: only the routes we need to exercise here.
+ */
+function renderAt(initialPath: string) {
+ const qc = makeQC();
+ return render(
+
+
+
+ {/* Known route — a lightweight stub so we can verify it wins */}
+ SearchPage} />
+ {/* Catch-all — must come last */}
+ } />
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("NotFound — catch-all 404 route", () => {
+ it("renders '404' heading at an unknown path", () => {
+ renderAt("/this/does/not/exist");
+ expect(screen.getByText("404")).toBeDefined();
+ });
+
+ it("displays the unrecognised path in the message", () => {
+ renderAt("/totally/unknown");
+ // The component renders the pathname in a block
+ expect(screen.getByText("/totally/unknown")).toBeDefined();
+ });
+
+ it("shows 'Page not found' heading", () => {
+ renderAt("/bad-path");
+ expect(screen.getByRole("heading", { name: /page not found/i })).toBeDefined();
+ });
+
+ it("renders at least one link back to the home page", () => {
+ renderAt("/nonexistent");
+ // The nav list includes a "Home" link pointing to "/"
+ const homeLink = screen.getByRole("link", { name: /home/i });
+ expect(homeLink).toBeDefined();
+ expect((homeLink as HTMLAnchorElement).getAttribute("href")).toBe("/");
+ });
+
+ it("renders all five quick-nav links", () => {
+ renderAt("/nonexistent");
+ const links = ["Home", "Search", "Graph", "XDR Inspector", "Sandbox"];
+ links.forEach((label) => {
+ expect(screen.getByRole("link", { name: new RegExp(label, "i") })).toBeDefined();
+ });
+ });
+
+ it("renders the go-back button", () => {
+ renderAt("/nonexistent");
+ expect(screen.getByRole("button", { name: /go back/i })).toBeDefined();
+ });
+});
+
+describe("Known routes — unaffected by catch-all", () => {
+ it("renders SearchPage at /search, not the NotFound page", () => {
+ renderAt("/search");
+ // Known route content should appear
+ expect(screen.getByText("SearchPage")).toBeDefined();
+ // 404 heading must NOT be visible
+ expect(screen.queryByText("404")).toBeNull();
+ });
+
+ it("does not show NotFound at the root path", () => {
+ const qc = makeQC();
+ render(
+
+
+
+ HomePage} />
+ } />
+
+
+ ,
+ );
+ expect(screen.getByText("HomePage")).toBeDefined();
+ expect(screen.queryByText("404")).toBeNull();
+ });
+});